Compare commits
29 Commits
0.4.2
...
6375b215cb
| Author | SHA1 | Date | |
|---|---|---|---|
| 6375b215cb | |||
| 25df79eef5 | |||
| 9c251b7a66 | |||
| c24be9f366 | |||
| c659d632b0 | |||
| 14827288e7 | |||
| 2410b01f15 | |||
| 67c774204a | |||
| c56012134c | |||
| 6d54e72f1d | |||
| df6c85d9ff | |||
| 8106f61d6a | |||
| 0a8dc74d5c | |||
| 8475e4d068 | |||
| 3f01ef5968 | |||
| 67992e65ec | |||
| 0b12323921 | |||
| 6e633d2880 | |||
| 7bdf6f8417 | |||
| 291d75b137 | |||
| a126613739 | |||
| 036b5f80dd | |||
| e818765b4f | |||
| 3d59a6dabf | |||
| cf69ad2957 | |||
| cdfecaf5eb | |||
| 4036fee1f5 | |||
| 1687a2b790 | |||
| a605195646 |
+8
-1
@@ -51,4 +51,11 @@ src/auracast/available_samples.txt
|
||||
src/auracast/server/stream_settings2.json
|
||||
src/scripts/temperature_log*
|
||||
|
||||
src/auracast/server/recordings/
|
||||
src/auracast/server/recordings/
|
||||
src/auracast/server/led_settings.json
|
||||
|
||||
|
||||
# Dante license files
|
||||
*.lic
|
||||
src/dep/dante_package/dante_data/activation/device.lic
|
||||
src/dep/dante_package/dante_data/activation/manufacturer.cert
|
||||
|
||||
Generated
+33
-46
@@ -270,51 +270,6 @@ optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "av-14.4.0-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:10219620699a65b9829cfa08784da2ed38371f1a223ab8f3523f440a24c8381c"},
|
||||
{file = "av-14.4.0-cp310-cp310-macosx_12_0_x86_64.whl", hash = "sha256:8bac981fde1c05e231df9f73a06ed9febce1f03fb0f1320707ac2861bba2567f"},
|
||||
{file = "av-14.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cc634ed5bdeb362f0523b73693b079b540418d35d7f3003654f788ae6c317eef"},
|
||||
{file = "av-14.4.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:23973ed5c5bec9565094d2b3643f10a6996707ddffa5252e112d578ad34aa9ae"},
|
||||
{file = "av-14.4.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0655f7207db6a211d7cedb8ac6a2f7ccc9c4b62290130e393a3fd99425247311"},
|
||||
{file = "av-14.4.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:1edaab73319bfefe53ee09c4b1cf7b141ea7e6678a0a1c62f7bac1e2c68ec4e7"},
|
||||
{file = "av-14.4.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b54838fa17c031ffd780df07b9962fac1be05220f3c28468f7fe49474f1bf8d2"},
|
||||
{file = "av-14.4.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f4b59ac6c563b9b6197299944145958a8ec34710799fd851f1a889b0cbcd1059"},
|
||||
{file = "av-14.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:a0192a584fae9f6cedfac03c06d5bf246517cdf00c8779bc33414404796a526e"},
|
||||
{file = "av-14.4.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:5b21d5586a88b9fce0ab78e26bd1c38f8642f8e2aad5b35e619f4d202217c701"},
|
||||
{file = "av-14.4.0-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:cf8762d90b0f94a20c9f6e25a94f1757db5a256707964dfd0b1d4403e7a16835"},
|
||||
{file = "av-14.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c0ac9f08920c7bbe0795319689d901e27cb3d7870b9a0acae3f26fc9daa801a6"},
|
||||
{file = "av-14.4.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a56d9ad2afdb638ec0404e962dc570960aae7e08ae331ad7ff70fbe99a6cf40e"},
|
||||
{file = "av-14.4.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6bed513cbcb3437d0ae47743edc1f5b4a113c0b66cdd4e1aafc533abf5b2fbf2"},
|
||||
{file = "av-14.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d030c2d3647931e53d51f2f6e0fcf465263e7acf9ec6e4faa8dbfc77975318c3"},
|
||||
{file = "av-14.4.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1cc21582a4f606271d8c2036ec7a6247df0831050306c55cf8a905701d0f0474"},
|
||||
{file = "av-14.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ce7c9cd452153d36f1b1478f904ed5f9ab191d76db873bdd3a597193290805d4"},
|
||||
{file = "av-14.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:fd261e31cc6b43ca722f80656c39934199d8f2eb391e0147e704b6226acebc29"},
|
||||
{file = "av-14.4.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:a53e682b239dd23b4e3bc9568cfb1168fc629ab01925fdb2e7556eb426339e94"},
|
||||
{file = "av-14.4.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:5aa0b901751a32703fa938d2155d56ce3faf3630e4a48d238b35d2f7e49e5395"},
|
||||
{file = "av-14.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a3b316fed3597675fe2aacfed34e25fc9d5bb0196dc8c0b014ae5ed4adda48de"},
|
||||
{file = "av-14.4.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a587b5c5014c3c0e16143a0f8d99874e46b5d0c50db6111aa0b54206b5687c81"},
|
||||
{file = "av-14.4.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10d53f75e8ac1ec8877a551c0db32a83c0aaeae719d05285281eaaba211bbc30"},
|
||||
{file = "av-14.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c8558cfde79dd8fc92d97c70e0f0fa8c94c7a66f68ae73afdf58598f0fe5e10d"},
|
||||
{file = "av-14.4.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:455b6410dea0ab2d30234ffb28df7d62ca3cdf10708528e247bec3a4cdcced09"},
|
||||
{file = "av-14.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1661efbe9d975f927b8512d654704223d936f39016fad2ddab00aee7c40f412c"},
|
||||
{file = "av-14.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:fbbeef1f421a3461086853d6464ad5526b56ffe8ccb0ab3fd0a1f121dfbf26ad"},
|
||||
{file = "av-14.4.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:3d2aea7c602b105363903e4017103bc4b60336e7aff80e1c22e8b4ec09fd125f"},
|
||||
{file = "av-14.4.0-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:38c18f036aeb6dc9abf5e867d998c867f9ec93a5f722b60721fdffc123bbb2ae"},
|
||||
{file = "av-14.4.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:58c1e18c8be73b6eada2d9ec397852ec74ebe51938451bdf83644a807189d6c8"},
|
||||
{file = "av-14.4.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e4c32ff03a357feb030634f093089a73cb474b04efe7fbfba31f229cb2fab115"},
|
||||
{file = "av-14.4.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:af31d16ae25964a6a02e09cc132b9decd5ee493c5dcb21bcdf0d71b2d6adbd59"},
|
||||
{file = "av-14.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e9fb297009e528f4851d25f3bb2781b2db18b59b10aed10240e947b77c582fb7"},
|
||||
{file = "av-14.4.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:573314cb9eafec2827dc98c416c965330dc7508193adbccd281700d8673b9f0a"},
|
||||
{file = "av-14.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f82ab27ee57c3b80eb50a5293222307dfdc02f810ea41119078cfc85ea3cf9a8"},
|
||||
{file = "av-14.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:9f682003bbcaac620b52f68ff0e85830fff165dea53949e217483a615993ca20"},
|
||||
{file = "av-14.4.0-cp39-cp39-macosx_12_0_arm64.whl", hash = "sha256:8ff683777e0bb3601f7cfb4545dca25db92817585330b773e897e1f6f9d612f7"},
|
||||
{file = "av-14.4.0-cp39-cp39-macosx_12_0_x86_64.whl", hash = "sha256:fe372acf7b1814bc2b16d89161609db63f81dad88684da76d26dd32cd1c16f92"},
|
||||
{file = "av-14.4.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:de869030eb8acfdfe39f39965de3a899dcde9b08df2db41f183c6166ca6f6d09"},
|
||||
{file = "av-14.4.0-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9117ed91fba6299b7d5233dd3e471770bab829f97e5a157f182761e9fb59254c"},
|
||||
{file = "av-14.4.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:54e8f9209184098b7755e6250be8ffa48a8aa5b554a02555406120583da17373"},
|
||||
{file = "av-14.4.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:38ea51e62a014663caec7f621d6601cf269ef450f3c8705f5e3225e5623fd15d"},
|
||||
{file = "av-14.4.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:5d1d89842efe913448482573a253bd6955ce30a77f8a4cd04a1a3537cc919896"},
|
||||
{file = "av-14.4.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:c3048e333da1367a2bca47e69593e10bc70f027d876adee9d1582c8cb818f36a"},
|
||||
{file = "av-14.4.0-cp39-cp39-win_amd64.whl", hash = "sha256:5d6f25570d0782dd05640c7e1f71cb29857d94d915b5521a1e757ecae78a5a50"},
|
||||
{file = "av-14.4.0.tar.gz", hash = "sha256:3ecbf803a7fdf67229c0edada0830d6bfaea4d10bfb24f0c3f4e607cd1064b42"},
|
||||
]
|
||||
|
||||
@@ -1849,6 +1804,22 @@ files = [
|
||||
{file = "protobuf-6.30.2.tar.gz", hash = "sha256:35c859ae076d8c56054c25b59e5e59638d86545ed6e2b6efac6be0b6ea3ba048"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyalsaaudio"
|
||||
version = "0.11.0"
|
||||
description = "ALSA bindings"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
groups = ["main"]
|
||||
files = []
|
||||
develop = false
|
||||
|
||||
[package.source]
|
||||
type = "git"
|
||||
url = "ssh://git@gitea.summitwave.work:222/auracaster/sw_pyalsaaudio.git"
|
||||
reference = "b3d11582e03df6929b2e7acbaa1306afc7b8a6bc"
|
||||
resolved_reference = "b3d11582e03df6929b2e7acbaa1306afc7b8a6bc"
|
||||
|
||||
[[package]]
|
||||
name = "pyarrow"
|
||||
version = "20.0.0"
|
||||
@@ -2443,6 +2414,22 @@ files = [
|
||||
{file = "rpds_py-0.25.1.tar.gz", hash = "sha256:8960b6dac09b62dac26e75d7e2c4a22efb835d827a7278c34f72b2b84fa160e3"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rpi-gpio"
|
||||
version = "0.7.1"
|
||||
description = "A module to control Raspberry Pi GPIO channels"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "RPi.GPIO-0.7.1-cp27-cp27mu-linux_armv6l.whl", hash = "sha256:b86b66dc02faa5461b443a1e1f0c1d209d64ab5229696f32fb3b0215e0600c8c"},
|
||||
{file = "RPi.GPIO-0.7.1-cp310-cp310-linux_armv6l.whl", hash = "sha256:57b6c044ef5375a78c8dda27cdfadf329e76aa6943cd6cffbbbd345a9adf9ca5"},
|
||||
{file = "RPi.GPIO-0.7.1-cp37-cp37m-linux_armv6l.whl", hash = "sha256:77afb817b81331ce3049a4b8f94a85e41b7c404d8e56b61ac0f1eb75c3120868"},
|
||||
{file = "RPi.GPIO-0.7.1-cp38-cp38-linux_armv6l.whl", hash = "sha256:29226823da8b5ccb9001d795a944f2e00924eeae583490f0bc7317581172c624"},
|
||||
{file = "RPi.GPIO-0.7.1-cp39-cp39-linux_armv6l.whl", hash = "sha256:15311d3b063b71dee738cd26570effc9985a952454d162937c34e08c0fc99902"},
|
||||
{file = "RPi.GPIO-0.7.1.tar.gz", hash = "sha256:cd61c4b03c37b62bba4a5acfea9862749c33c618e0295e7e90aa4713fb373b70"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "samplerate"
|
||||
version = "0.2.2"
|
||||
@@ -2976,4 +2963,4 @@ test = ["pytest", "pytest-asyncio"]
|
||||
[metadata]
|
||||
lock-version = "2.1"
|
||||
python-versions = ">=3.11"
|
||||
content-hash = "3c9f92c7a5af40f98da9c7824d9c2a6f7eb809e91e43cfef4995761b2e887256"
|
||||
content-hash = "7bccf2978170ead195e1e8cff151823a5276823195a239622186fcec830154d9"
|
||||
|
||||
+3
-1
@@ -17,7 +17,9 @@ dependencies = [
|
||||
"sounddevice (>=0.5.2,<0.6.0)",
|
||||
"python-dotenv (>=1.1.1,<2.0.0)",
|
||||
"smbus2 (>=0.5.0,<0.6.0)",
|
||||
"samplerate (>=0.2.2,<0.3.0)"
|
||||
"samplerate (>=0.2.2,<0.3.0)",
|
||||
"rpi-gpio (>=0.7.1,<0.8.0)",
|
||||
"pyalsaaudio @ git+ssh://git@gitea.summitwave.work:222/auracaster/sw_pyalsaaudio.git@b3d11582e03df6929b2e7acbaa1306afc7b8a6bc"
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
@@ -55,7 +55,6 @@ class AuracastBigConfig(BaseModel):
|
||||
precode_wav: bool = False
|
||||
iso_que_len: int = 64
|
||||
num_bis: int = 1 # 1 = mono (FRONT_LEFT), 2 = stereo (FRONT_LEFT + FRONT_RIGHT)
|
||||
input_gain_db: float = 0.0 # Software gain boost in dB applied before LC3 encoding (0 = off, max 20)
|
||||
|
||||
class AuracastBigConfigDeu(AuracastBigConfig):
|
||||
id: int = 12
|
||||
@@ -112,4 +111,5 @@ class AuracastConfigGroup(AuracastGlobalConfig):
|
||||
bigs: List[AuracastBigConfig] = [
|
||||
AuracastBigConfigDeu(),
|
||||
]
|
||||
analog_gain: int = 50 # ADC gain level for analog mode (10-60%)
|
||||
analog_gain_db_left: float = 0.0 # ADC gain level for analog mode left channel (-12 to 18 dB)
|
||||
analog_gain_db_right: float = 0.0 # ADC gain level for analog mode right channel (-12 to 18 dB)
|
||||
|
||||
+130
-90
@@ -30,6 +30,7 @@ import time
|
||||
import threading
|
||||
|
||||
import numpy as np # for audio down-mix
|
||||
import samplerate
|
||||
import os
|
||||
|
||||
import lc3 # type: ignore # pylint: disable=E0401
|
||||
@@ -56,7 +57,7 @@ from auracast.utils.webrtc_audio_input import WebRTCAudioInput
|
||||
|
||||
|
||||
# Patch sounddevice.InputStream globally to use low-latency settings
|
||||
import sounddevice as sd
|
||||
import alsaaudio
|
||||
from collections import deque
|
||||
|
||||
|
||||
@@ -139,96 +140,146 @@ class AlsaArecordAudioInput(audio_io.AudioInput):
|
||||
self._proc = None
|
||||
|
||||
|
||||
class ModSoundDeviceAudioInput(audio_io.SoundDeviceAudioInput):
|
||||
"""Patched SoundDeviceAudioInput with low-latency capture and adaptive resampling."""
|
||||
class PyAlsaAudioInput(audio_io.ThreadedAudioInput):
|
||||
"""PyALSA audio input with non-blocking reads - supports mono/stereo."""
|
||||
|
||||
def _open(self):
|
||||
"""Create RawInputStream with low-latency parameters and initialize ring buffer."""
|
||||
dev_info = sd.query_devices(self._device)
|
||||
hostapis = sd.query_hostapis()
|
||||
api_index = dev_info.get('hostapi')
|
||||
api_name = hostapis[api_index]['name'] if isinstance(api_index, int) and 0 <= api_index < len(hostapis) else 'unknown'
|
||||
pa_ver = sd.get_portaudio_version()
|
||||
def __init__(self, device, pcm_format: audio_io.PcmFormat):
|
||||
super().__init__()
|
||||
logging.info("PyALSA: device = %s", device)
|
||||
self._device = str(device) if not isinstance(device, str) else device
|
||||
if self._device.isdigit():
|
||||
self._device = 'default' if self._device == '0' else f'hw:{self._device}'
|
||||
self._pcm_format = pcm_format
|
||||
self._pcm = None
|
||||
self._actual_channels = None
|
||||
self._periodsize = None
|
||||
self._hw_channels = None
|
||||
self._first_read = True
|
||||
self._resampler = None
|
||||
self._resampler_buffer = np.empty(0, dtype=np.float32)
|
||||
|
||||
logging.info(
|
||||
"SoundDevice backend=%s device='%s' (id=%s) ch=%s default_low_input_latency=%.4f default_high_input_latency=%.4f portaudio=%s",
|
||||
api_name,
|
||||
dev_info.get('name'),
|
||||
self._device,
|
||||
dev_info.get('max_input_channels'),
|
||||
float(dev_info.get('default_low_input_latency') or 0.0),
|
||||
float(dev_info.get('default_high_input_latency') or 0.0),
|
||||
pa_ver[1] if isinstance(pa_ver, tuple) and len(pa_ver) >= 2 else pa_ver,
|
||||
)
|
||||
# Create RawInputStream with injected low-latency parameters
|
||||
# Target ~2 ms blocksize (48 kHz -> 96 frames). For other rates, keep ~2 ms.
|
||||
_sr = int(self._pcm_format.sample_rate)
|
||||
|
||||
self.counter=0
|
||||
self.max_avail=0
|
||||
self.logfile_name="available_samples.txt"
|
||||
self.blocksize = 120
|
||||
|
||||
if os.path.exists(self.logfile_name):
|
||||
os.remove(self.logfile_name)
|
||||
|
||||
self._stream = sd.RawInputStream(
|
||||
samplerate=self._pcm_format.sample_rate,
|
||||
def _open(self) -> audio_io.PcmFormat:
|
||||
ALSA_PERIODSIZE = 240
|
||||
ALSA_PERIODS = 4
|
||||
ALSA_MODE = alsaaudio.PCM_NONBLOCK
|
||||
|
||||
requested_rate = int(self._pcm_format.sample_rate)
|
||||
requested_channels = int(self._pcm_format.channels)
|
||||
self._periodsize = ALSA_PERIODSIZE
|
||||
|
||||
self._pcm = alsaaudio.PCM(
|
||||
type=alsaaudio.PCM_CAPTURE,
|
||||
mode=ALSA_MODE,
|
||||
device=self._device,
|
||||
channels=self._pcm_format.channels,
|
||||
dtype='int16',
|
||||
blocksize=self.blocksize,
|
||||
latency=0.004,
|
||||
periods=ALSA_PERIODS,
|
||||
)
|
||||
self._stream.start()
|
||||
|
||||
self._pcm.setchannels(requested_channels)
|
||||
self._pcm.setformat(alsaaudio.PCM_FORMAT_S16_LE)
|
||||
actual_rate = self._pcm.setrate(requested_rate)
|
||||
self._pcm.setperiodsize(ALSA_PERIODSIZE)
|
||||
|
||||
logging.info("PyALSA: device=%s rate=%d ch=%d periodsize=%d (%.1fms) periods=%d mode=%s",
|
||||
self._device, actual_rate, requested_channels, ALSA_PERIODSIZE,
|
||||
(ALSA_PERIODSIZE / actual_rate) * 1000, ALSA_PERIODS, ALSA_MODE)
|
||||
|
||||
if actual_rate != requested_rate:
|
||||
logging.warning("PyALSA: Sample rate mismatch! requested=%d actual=%d", requested_rate, actual_rate)
|
||||
|
||||
self._actual_channels = requested_channels
|
||||
self._resampler = samplerate.Resampler('sinc_fastest', channels=requested_channels)
|
||||
self._resampler_buffer = np.empty(0, dtype=np.float32)
|
||||
|
||||
self._bang_bang = 0
|
||||
|
||||
return audio_io.PcmFormat(
|
||||
audio_io.PcmFormat.Endianness.LITTLE,
|
||||
audio_io.PcmFormat.SampleType.INT16,
|
||||
self._pcm_format.sample_rate,
|
||||
1,
|
||||
actual_rate,
|
||||
requested_channels,
|
||||
)
|
||||
|
||||
def _read(self, frame_size: int) -> bytes:
|
||||
"""Read PCM samples from the stream."""
|
||||
try:
|
||||
avail = self._pcm.avail()
|
||||
logging.debug("PyALSA: avail before read: %d", avail)
|
||||
length, data = self._pcm.read_sw(frame_size + self._bang_bang)
|
||||
avail = self._pcm.avail()
|
||||
SETPOINT = 120
|
||||
TOLERANCE = 80
|
||||
if avail < SETPOINT - TOLERANCE:
|
||||
self._bang_bang = -1
|
||||
elif avail > SETPOINT + TOLERANCE:
|
||||
self._bang_bang = 1
|
||||
else:
|
||||
self._bang_bang = 0
|
||||
|
||||
|
||||
logging.debug("PyALSA: read length=%d, data length=%d, avail=%d, bang_bang=%d", length, len(data), avail, self._bang_bang)
|
||||
|
||||
#if self.counter % 50 == 0:
|
||||
frame_size = frame_size + 1 # consume samples a little faster to avoid latency akkumulation
|
||||
if length > 0:
|
||||
if self._first_read:
|
||||
expected_mono = self._periodsize * 2
|
||||
expected_stereo = self._periodsize * 2 * 2
|
||||
# self._hw_channels = 2 if len(data) == expected_stereo else 1
|
||||
self._hw_channels = self._actual_channels
|
||||
logging.info("PyALSA first read: bytes=%d detected_hw_channels=%d requested_channels=%d",
|
||||
len(data), self._hw_channels, self._actual_channels)
|
||||
self._first_read = False
|
||||
|
||||
if self._hw_channels == 2 and self._actual_channels == 1:
|
||||
pcm_stereo = np.frombuffer(data, dtype=np.int16)
|
||||
pcm_mono = pcm_stereo[::2]
|
||||
data = pcm_mono.tobytes()
|
||||
|
||||
actual_samples = len(data) // (2 * self._actual_channels)
|
||||
ratio = frame_size / actual_samples
|
||||
pcm_f32 = np.frombuffer(data, dtype=np.int16).astype(np.float32) / 32768.0
|
||||
if self._actual_channels > 1:
|
||||
pcm_f32 = pcm_f32.reshape(-1, self._actual_channels)
|
||||
resampled = self._resampler.process(pcm_f32, ratio, end_of_input=False)
|
||||
if self._actual_channels > 1:
|
||||
resampled = resampled.reshape(-1)
|
||||
self._resampler_buffer = np.concatenate([self._resampler_buffer, resampled])
|
||||
else:
|
||||
logging.warning("PyALSA: No data read from ALSA")
|
||||
self._resampler_buffer = np.concatenate([
|
||||
self._resampler_buffer,
|
||||
np.zeros(frame_size * self._actual_channels, dtype=np.float32),
|
||||
])
|
||||
except alsaaudio.ALSAAudioError as e:
|
||||
logging.error("PyALSA: ALSA read error: %s", e)
|
||||
self._resampler_buffer = np.concatenate([
|
||||
self._resampler_buffer,
|
||||
np.zeros(frame_size * self._actual_channels, dtype=np.float32),
|
||||
])
|
||||
except Exception as e:
|
||||
logging.error("PyALSA: Unexpected error in _read: %s", e, exc_info=True)
|
||||
self._resampler_buffer = np.concatenate([
|
||||
self._resampler_buffer,
|
||||
np.zeros(frame_size * self._actual_channels, dtype=np.float32),
|
||||
])
|
||||
|
||||
pcm_buffer, overflowed = self._stream.read(frame_size)
|
||||
if overflowed:
|
||||
logging.warning("SoundDeviceAudioInput: overflowed")
|
||||
needed = frame_size * self._actual_channels
|
||||
if len(self._resampler_buffer) < needed:
|
||||
pad = np.zeros(needed - len(self._resampler_buffer), dtype=np.float32)
|
||||
self._resampler_buffer = np.concatenate([self._resampler_buffer, pad])
|
||||
logging.debug("PyALSA: padded buffer with %d samples", needed - len(self._resampler_buffer))
|
||||
|
||||
n_available = self._stream.read_available
|
||||
output = self._resampler_buffer[:needed]
|
||||
self._resampler_buffer = self._resampler_buffer[needed:]
|
||||
|
||||
# adapt = n_available > 20
|
||||
# if adapt:
|
||||
# pcm_extra, overflowed = self._stream.read(3)
|
||||
# logging.info('consuming extra samples, available was %d', n_available)
|
||||
# if overflowed:
|
||||
# logging.warning("SoundDeviceAudioInput: overflowed")
|
||||
|
||||
# out = bytes(pcm_buffer) + bytes(pcm_extra)
|
||||
# else:
|
||||
out = bytes(pcm_buffer)
|
||||
logging.debug("PyALSA: resampler_buffer remaining=%d", len(self._resampler_buffer))
|
||||
return np.clip(output * 32767.0, -32768, 32767).astype(np.int16).tobytes()
|
||||
|
||||
self.max_avail = max(self.max_avail, n_available)
|
||||
|
||||
|
||||
#Diagnostics
|
||||
#with open(self.logfile_name, "a", encoding="utf-8") as f:
|
||||
# f.write(f"{n_available}, {adapt}, {round(self._runavg, 2)}, {overflowed}\n")
|
||||
def _close(self) -> None:
|
||||
if self._pcm:
|
||||
self._pcm.close()
|
||||
self._pcm = None
|
||||
|
||||
if self.counter % 500 == 0:
|
||||
logging.info(
|
||||
"read available=%d, max=%d, latency:%d",
|
||||
n_available, self.max_avail, self._stream.latency
|
||||
)
|
||||
self.max_avail = 0
|
||||
|
||||
self.counter += 1
|
||||
return out
|
||||
|
||||
audio_io.SoundDeviceAudioInput = ModSoundDeviceAudioInput
|
||||
audio_io.SoundDeviceAudioInput = PyAlsaAudioInput
|
||||
|
||||
# modified from bumble
|
||||
class ModWaveAudioInput(audio_io.ThreadedAudioInput):
|
||||
@@ -538,7 +589,7 @@ async def init_broadcast(
|
||||
|
||||
def on_flow():
|
||||
data_packet_queue = iso_queue.data_packet_queue
|
||||
print(
|
||||
logging.info(
|
||||
f'\rPACKETS: pending={data_packet_queue.pending}, '
|
||||
f'queued={data_packet_queue.queued}, '
|
||||
f'completed={data_packet_queue.completed}',
|
||||
@@ -760,7 +811,11 @@ class Streamer():
|
||||
if input_format == 'auto':
|
||||
raise ValueError('input format details required for alsa input')
|
||||
pcm = audio_io.PcmFormat.from_str(input_format)
|
||||
audio_input = AlsaArecordAudioInput(audio_source[5:], pcm)
|
||||
device_name = audio_source[5:]
|
||||
if device_name.startswith('dante_'):
|
||||
audio_input = PyAlsaAudioInput(device_name, pcm)
|
||||
else:
|
||||
audio_input = AlsaArecordAudioInput(device_name, pcm)
|
||||
else:
|
||||
audio_input = await audio_io.create_audio_input(audio_source, input_format)
|
||||
# Store early so stop_streaming can close even if open() fails
|
||||
@@ -811,13 +866,6 @@ class Streamer():
|
||||
big['encoder'] = encoders[0]
|
||||
big['precoded'] = False
|
||||
|
||||
# Pre-compute software gain multiplier from dB config (0 dB = 1.0 = no change)
|
||||
gain_db = getattr(big_config[i], 'input_gain_db', 0.0)
|
||||
gain_db = max(0.0, min(20.0, float(gain_db)))
|
||||
big['_gain_linear'] = 10.0 ** (gain_db / 20.0) if gain_db > 0 else 0.0
|
||||
if big['_gain_linear'] > 0.0:
|
||||
logging.info("Software gain for BIG %d: +%.1f dB (linear %.3f)", i, gain_db, big['_gain_linear'])
|
||||
|
||||
logging.info("Streaming audio...")
|
||||
bigs = self.bigs
|
||||
self.is_streaming = True
|
||||
@@ -865,14 +913,6 @@ class Streamer():
|
||||
stream_finished[i] = True
|
||||
continue
|
||||
|
||||
# Apply software gain boost if configured (> 0 dB)
|
||||
gain_lin = big.get('_gain_linear', 0.0)
|
||||
if gain_lin > 0.0:
|
||||
pcm_arr = np.frombuffer(pcm_frame, dtype=np.int16).astype(np.float32)
|
||||
pcm_arr *= gain_lin
|
||||
np.clip(pcm_arr, -32768, 32767, out=pcm_arr)
|
||||
pcm_frame = pcm_arr.astype(np.int16).tobytes()
|
||||
|
||||
# Compute RMS audio level (normalized 0.0-1.0) for level monitoring
|
||||
pcm_samples = np.frombuffer(pcm_frame, dtype=np.int16).astype(np.float32)
|
||||
rms = np.sqrt(np.mean(pcm_samples ** 2)) / 32768.0 if len(pcm_samples) > 0 else 0.0
|
||||
|
||||
@@ -143,6 +143,10 @@ async def main():
|
||||
level=os.environ.get('LOG_LEVEL', logging.DEBUG),
|
||||
format='%(module)s.py:%(lineno)d %(levelname)s: %(message)s'
|
||||
)
|
||||
|
||||
# Enable debug logging for bumble
|
||||
# logging.getLogger('bumble').setLevel(logging.DEBUG)
|
||||
|
||||
os.chdir(os.path.dirname(__file__))
|
||||
|
||||
global_conf = auracast_config.AuracastGlobalConfig(
|
||||
|
||||
@@ -71,6 +71,10 @@ if not is_pw_disabled():
|
||||
with st.form("signin_form"):
|
||||
pw = st.text_input("Password", type="password")
|
||||
submitted = st.form_submit_button("Sign in")
|
||||
st.components.v1.html(
|
||||
"<script>setTimeout(()=>window.parent.document.querySelector('input[type=\"password\"]')?.focus(),100)</script>",
|
||||
height=0
|
||||
)
|
||||
if submitted:
|
||||
if verify_password(pw, pw_rec):
|
||||
st.session_state['frontend_authenticated'] = True
|
||||
@@ -198,35 +202,64 @@ else:
|
||||
start_stream, stop_stream = render_stream_controls(is_streaming, "Start Auracast", "Stop Auracast", running_mode, secondary_is_streaming)
|
||||
|
||||
# Analog gain control (only for Analog mode, placed below start button)
|
||||
analog_gain_value = 50 # default (ALSA 10-60 range)
|
||||
software_boost_db = 0 # default
|
||||
analog_gain_db_left = 0 # default (dB)
|
||||
analog_gain_db_right = 0 # default (dB)
|
||||
if audio_mode == "Analog":
|
||||
saved_analog_gain = saved_settings.get('analog_gain', 50)
|
||||
# Convert persisted ALSA value (10-60) to display value (0-100)
|
||||
saved_display = int(round((saved_analog_gain - 10) * 100 / 50))
|
||||
saved_display = max(0, min(100, saved_display))
|
||||
analog_gain_display = st.slider(
|
||||
"Analog Input Gain",
|
||||
min_value=0,
|
||||
max_value=100,
|
||||
value=saved_display,
|
||||
step=5,
|
||||
disabled=is_streaming,
|
||||
format="%d%%",
|
||||
help="ADC gain level for both analog inputs. Default is 80%."
|
||||
)
|
||||
# Map display value (0-100) back to ALSA range (10-60)
|
||||
analog_gain_value = int(round(10 + analog_gain_display * 50 / 100))
|
||||
saved_boost = saved_settings.get('software_boost_db', 0)
|
||||
software_boost_db = st.slider(
|
||||
"Boost",
|
||||
min_value=0,
|
||||
max_value=20,
|
||||
value=min(int(saved_boost), 20),
|
||||
step=1,
|
||||
disabled=is_streaming,
|
||||
help="Digital gain boost applied before encoding (0-20 dB). Use this when the line-level signal is too quiet even at max ADC gain. Higher values may cause clipping on loud signals."
|
||||
if '_analog_gain_db_left' not in st.session_state:
|
||||
st.session_state['_analog_gain_db_left'] = max(-12, min(18, int(saved_settings.get('analog_gain_db_left', 0))))
|
||||
if '_analog_gain_db_right' not in st.session_state:
|
||||
st.session_state['_analog_gain_db_right'] = max(-12, min(18, int(saved_settings.get('analog_gain_db_right', 0))))
|
||||
if '_gain_link_channels' not in st.session_state:
|
||||
st.session_state['_gain_link_channels'] = True
|
||||
|
||||
link_channels = st.checkbox(
|
||||
"Link audio channel gain",
|
||||
key='_gain_link_channels',
|
||||
help="When enabled, Ch 2 mirrors Ch 1."
|
||||
)
|
||||
_gain_col1, _gain_col2 = st.columns(2)
|
||||
with _gain_col1:
|
||||
analog_gain_db_left = st.slider(
|
||||
"Ch 1 Input Gain",
|
||||
min_value=-12,
|
||||
max_value=18,
|
||||
key='_analog_gain_db_left',
|
||||
step=1,
|
||||
format="%d dB",
|
||||
help="ADC gain for channel 1 (-12 to 18 dB). Default is 0 dB."
|
||||
)
|
||||
with _gain_col2:
|
||||
if link_channels:
|
||||
st.session_state['_analog_gain_db_right'] = analog_gain_db_left
|
||||
elif st.session_state.get('_prev_gain_link_channels', True):
|
||||
# Transition: just unlinked — seed Ch 2 from Ch 1 so it doesn't jump to min
|
||||
st.session_state['_analog_gain_db_right'] = analog_gain_db_left
|
||||
st.session_state['_prev_gain_link_channels'] = link_channels
|
||||
analog_gain_db_right = st.slider(
|
||||
"Ch 2 Input Gain",
|
||||
min_value=-12,
|
||||
max_value=18,
|
||||
key='_analog_gain_db_right',
|
||||
step=1,
|
||||
format="%d dB",
|
||||
disabled=link_channels,
|
||||
help="Uncheck 'Link audio channel gain' to adjust Ch 2 independently." if link_channels else "ADC gain for channel 2 (-12 to 18 dB). Default is 0 dB."
|
||||
)
|
||||
# Apply gain live while streaming whenever either slider value changes
|
||||
if is_streaming:
|
||||
prev_left = st.session_state.get('_prev_analog_gain_db_left')
|
||||
prev_right = st.session_state.get('_prev_analog_gain_db_right')
|
||||
if prev_left != analog_gain_db_left or prev_right != analog_gain_db_right:
|
||||
try:
|
||||
requests.post(
|
||||
f"{BACKEND_URL}/adc_gain",
|
||||
json={"gain_db_left": analog_gain_db_left, "gain_db_right": analog_gain_db_right},
|
||||
timeout=1,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
st.session_state['_prev_analog_gain_db_left'] = analog_gain_db_left
|
||||
st.session_state['_prev_analog_gain_db_right'] = analog_gain_db_right
|
||||
|
||||
# Audio level monitor (checkbox, not persisted across reloads)
|
||||
show_level_monitor = st.checkbox("Audio level monitor", value=False, disabled=not is_streaming,
|
||||
@@ -404,13 +437,43 @@ else:
|
||||
disabled=is_streaming
|
||||
)
|
||||
|
||||
# Use analog-specific defaults (not from saved settings which may have Dante values)
|
||||
default_name = "Analog_Radio_1"
|
||||
default_program_info = "Analog Radio Broadcast"
|
||||
default_lang = "deu"
|
||||
|
||||
quality_options = list(QUALITY_MAP.keys())
|
||||
default_quality = "Medium (24kHz)" if "Medium (24kHz)" in quality_options else quality_options[0]
|
||||
|
||||
# Use saved settings if audio_mode matches, otherwise use analog-specific defaults
|
||||
saved_audio_mode = saved_settings.get('audio_mode')
|
||||
if saved_audio_mode == 'Analog':
|
||||
default_name = saved_settings.get('channel_names', ["Analog_Radio_1"])[0]
|
||||
raw_program_info = saved_settings.get('program_info', default_name)
|
||||
if isinstance(raw_program_info, list) and raw_program_info:
|
||||
default_program_info = raw_program_info[0]
|
||||
else:
|
||||
default_program_info = raw_program_info
|
||||
default_lang = saved_settings.get('languages', ["deu"])[0]
|
||||
|
||||
# Map saved sampling rate to quality label
|
||||
saved_rate = saved_settings.get('auracast_sampling_rate_hz')
|
||||
if saved_rate == 48000:
|
||||
default_quality = "High (48kHz)"
|
||||
elif saved_rate == 32000:
|
||||
default_quality = "Good (32kHz)"
|
||||
elif saved_rate == 24000:
|
||||
default_quality = "Medium (24kHz)"
|
||||
elif saved_rate == 16000:
|
||||
default_quality = "Fair (16kHz)"
|
||||
else:
|
||||
default_quality = "Medium (24kHz)"
|
||||
|
||||
saved_pwd = saved_settings.get('stream_password', '')
|
||||
else:
|
||||
# Use analog-specific defaults when switching from another mode
|
||||
default_name = "Analog_Radio_1"
|
||||
default_program_info = "Analog Radio Broadcast"
|
||||
default_lang = "deu"
|
||||
default_quality = "Medium (24kHz)" if "Medium (24kHz)" in quality_options else quality_options[0]
|
||||
saved_pwd = ''
|
||||
|
||||
if default_quality not in quality_options:
|
||||
default_quality = quality_options[0]
|
||||
quality1 = st.selectbox(
|
||||
"Stream Quality (Radio 1)",
|
||||
quality_options,
|
||||
@@ -421,7 +484,7 @@ else:
|
||||
|
||||
stream_passwort1 = st.text_input(
|
||||
"Stream Passwort (Radio 1)",
|
||||
value="",
|
||||
value=saved_pwd,
|
||||
type="password",
|
||||
disabled=is_streaming,
|
||||
help="Optional: Set a broadcast code for Radio 1."
|
||||
@@ -528,7 +591,10 @@ else:
|
||||
input_device1 = None
|
||||
else:
|
||||
# Mono mode: show all available channels
|
||||
saved_input_device = saved_settings.get('input_device')
|
||||
default_r1_idx = 0
|
||||
if saved_input_device in analog_names:
|
||||
default_r1_idx = analog_names.index(saved_input_device)
|
||||
input_device1 = st.selectbox(
|
||||
"Input Device (Radio 1)",
|
||||
analog_names,
|
||||
@@ -577,22 +643,53 @@ else:
|
||||
)
|
||||
|
||||
if radio2_enabled and not stereo_enabled:
|
||||
# Use analog-specific defaults for Radio 2
|
||||
default_name_r2 = "Analog_Radio_2"
|
||||
default_program_info_r2 = "Analog Radio Broadcast"
|
||||
default_lang_r2 = "deu"
|
||||
# Use saved settings if audio_mode matches, otherwise use analog-specific defaults for Radio 2
|
||||
secondary_settings = saved_settings.get('secondary', {})
|
||||
saved_audio_mode = saved_settings.get('audio_mode')
|
||||
if saved_audio_mode == 'Analog' and secondary_settings:
|
||||
default_name_r2 = secondary_settings.get('channel_names', ["Analog_Radio_2"])[0] if isinstance(secondary_settings.get('channel_names'), list) else secondary_settings.get('channel_names', "Analog_Radio_2")
|
||||
raw_program_info_r2 = secondary_settings.get('program_info', default_name_r2)
|
||||
if isinstance(raw_program_info_r2, list) and raw_program_info_r2:
|
||||
default_program_info_r2 = raw_program_info_r2[0]
|
||||
else:
|
||||
default_program_info_r2 = raw_program_info_r2
|
||||
default_lang_r2 = secondary_settings.get('languages', ["deu"])[0] if isinstance(secondary_settings.get('languages'), list) else secondary_settings.get('languages', 'deu')
|
||||
|
||||
# Map saved sampling rate to quality label
|
||||
saved_rate_r2 = secondary_settings.get('auracast_sampling_rate_hz')
|
||||
if saved_rate_r2 == 48000:
|
||||
default_quality_r2 = "High (48kHz)"
|
||||
elif saved_rate_r2 == 32000:
|
||||
default_quality_r2 = "Good (32kHz)"
|
||||
elif saved_rate_r2 == 24000:
|
||||
default_quality_r2 = "Medium (24kHz)"
|
||||
elif saved_rate_r2 == 16000:
|
||||
default_quality_r2 = "Fair (16kHz)"
|
||||
else:
|
||||
default_quality_r2 = "Medium (24kHz)"
|
||||
|
||||
saved_pwd_r2 = secondary_settings.get('stream_password', '')
|
||||
else:
|
||||
# Use analog-specific defaults when switching from another mode
|
||||
default_name_r2 = "Analog_Radio_2"
|
||||
default_program_info_r2 = "Analog Radio Broadcast"
|
||||
default_lang_r2 = "deu"
|
||||
default_quality_r2 = "Medium (24kHz)" if "Medium (24kHz)" in quality_options else quality_options[0]
|
||||
saved_pwd_r2 = ''
|
||||
|
||||
if default_quality_r2 not in quality_options:
|
||||
default_quality_r2 = quality_options[0]
|
||||
quality2 = st.selectbox(
|
||||
"Stream Quality (Radio 2)",
|
||||
quality_options,
|
||||
index=quality_options.index(default_quality),
|
||||
index=quality_options.index(default_quality_r2),
|
||||
disabled=is_streaming,
|
||||
help="Select the audio sampling rate for Radio 2."
|
||||
)
|
||||
|
||||
stream_passwort2 = st.text_input(
|
||||
"Stream Passwort (Radio 2)",
|
||||
value="",
|
||||
value=saved_pwd_r2,
|
||||
type="password",
|
||||
disabled=is_streaming,
|
||||
help="Optional: Set a broadcast code for Radio 2."
|
||||
@@ -653,7 +750,11 @@ else:
|
||||
|
||||
if not is_streaming:
|
||||
if analog_names:
|
||||
secondary_settings = saved_settings.get('secondary', {})
|
||||
saved_input_device2 = secondary_settings.get('input_device')
|
||||
default_r2_idx = 1 if len(analog_names) > 1 else 0
|
||||
if saved_input_device2 in analog_names:
|
||||
default_r2_idx = analog_names.index(saved_input_device2)
|
||||
input_device2 = st.selectbox(
|
||||
"Input Device (Radio 2)",
|
||||
analog_names,
|
||||
@@ -684,8 +785,8 @@ else:
|
||||
'immediate_rendering': immediate_rendering2,
|
||||
'presentation_delay_ms': presentation_delay_ms2,
|
||||
'qos_preset': qos_preset2,
|
||||
'analog_gain': analog_gain_value,
|
||||
'software_boost_db': software_boost_db,
|
||||
'analog_gain_db_left': analog_gain_db_left,
|
||||
'analog_gain_db_right': analog_gain_db_right,
|
||||
}
|
||||
|
||||
radio1_cfg = {
|
||||
@@ -701,8 +802,8 @@ else:
|
||||
'presentation_delay_ms': presentation_delay_ms1,
|
||||
'qos_preset': qos_preset1,
|
||||
'stereo_mode': stereo_enabled,
|
||||
'analog_gain': analog_gain_value,
|
||||
'software_boost_db': software_boost_db,
|
||||
'analog_gain_db_left': analog_gain_db_left,
|
||||
'analog_gain_db_right': analog_gain_db_right,
|
||||
}
|
||||
|
||||
if audio_mode == "Network - Dante":
|
||||
@@ -745,10 +846,15 @@ else:
|
||||
)
|
||||
|
||||
# Dante stereo mode toggle
|
||||
saved_r1_config = saved_settings.get('dante_radio1', {})
|
||||
saved_audio_mode = saved_settings.get('audio_mode')
|
||||
dante_stereo_enabled = False
|
||||
if saved_audio_mode == 'Network - Dante':
|
||||
# Check if any input device starts with dante_stereo_ to detect stereo mode
|
||||
input_device = saved_settings.get('input_device', '')
|
||||
dante_stereo_enabled = input_device.startswith('dante_stereo_')
|
||||
dante_stereo_enabled = st.checkbox(
|
||||
"🎧 Stereo Mode",
|
||||
value=bool(saved_r1_config.get('dante_stereo_mode', False)),
|
||||
value=dante_stereo_enabled,
|
||||
help="Enable stereo streaming for Dante inputs. Select left and right channels from ASRC channels 1-6. Radio 2 and multi-stream configurations will be disabled in stereo mode.",
|
||||
disabled=is_streaming
|
||||
)
|
||||
@@ -757,13 +863,23 @@ else:
|
||||
dante_left_channel = None
|
||||
dante_right_channel = None
|
||||
if dante_stereo_enabled:
|
||||
dante_channel_options = ["dante_asrc_ch1", "dante_asrc_ch2", "dante_asrc_ch3",
|
||||
dante_channel_options = ["dante_asrc_ch1", "dante_asrc_ch2", "dante_asrc_ch3",
|
||||
"dante_asrc_ch4", "dante_asrc_ch5", "dante_asrc_ch6"]
|
||||
dante_channel_labels = ["CH1", "CH2", "CH3", "CH4", "CH5", "CH6"]
|
||||
|
||||
|
||||
# Parse saved stereo device name to extract left and right channels
|
||||
input_device = saved_settings.get('input_device', '')
|
||||
saved_left = 'dante_asrc_ch1'
|
||||
saved_right = 'dante_asrc_ch2'
|
||||
if input_device.startswith('dante_stereo_'):
|
||||
# Format: dante_stereo_<left>_<right>
|
||||
parts = input_device.split('_')
|
||||
if len(parts) >= 4:
|
||||
saved_left = f"dante_asrc_ch{parts[2]}"
|
||||
saved_right = f"dante_asrc_ch{parts[3]}"
|
||||
|
||||
col_left, col_right = st.columns(2)
|
||||
with col_left:
|
||||
saved_left = saved_r1_config.get('dante_stereo_left', 'dante_asrc_ch1')
|
||||
left_idx = dante_channel_options.index(saved_left) if saved_left in dante_channel_options else 0
|
||||
dante_left_channel = st.selectbox(
|
||||
"Left Channel",
|
||||
@@ -774,7 +890,6 @@ else:
|
||||
help="Select the Dante ASRC channel for the left stereo channel"
|
||||
)
|
||||
with col_right:
|
||||
saved_right = saved_r1_config.get('dante_stereo_right', 'dante_asrc_ch2')
|
||||
right_idx = dante_channel_options.index(saved_right) if saved_right in dante_channel_options else 1
|
||||
dante_right_channel = st.selectbox(
|
||||
"Right Channel",
|
||||
@@ -784,7 +899,7 @@ else:
|
||||
disabled=is_streaming,
|
||||
help="Select the Dante ASRC channel for the right stereo channel"
|
||||
)
|
||||
|
||||
|
||||
if dante_left_channel == dante_right_channel:
|
||||
st.warning("⚠️ Left and right channels are the same. Select different channels for true stereo.")
|
||||
else:
|
||||
@@ -792,7 +907,22 @@ else:
|
||||
|
||||
# Stream count dropdown for Radio 1 (disabled in stereo mode - forced to 1 stream at 48kHz)
|
||||
r1_stream_options = list(dante_stream_options.keys())
|
||||
saved_r1_streams = saved_r1_config.get('stream_config', '1x48')
|
||||
# Infer stream configuration from saved sampling rate
|
||||
saved_rate = saved_settings.get('auracast_sampling_rate_hz')
|
||||
saved_r1_streams = '1 × 48kHz' # default
|
||||
if saved_rate:
|
||||
if saved_rate == 48000:
|
||||
channel_names = saved_settings.get('channel_names', [])
|
||||
if len(channel_names) == 2:
|
||||
saved_r1_streams = '2 × 24kHz'
|
||||
elif len(channel_names) == 3:
|
||||
saved_r1_streams = '3 × 16kHz'
|
||||
else:
|
||||
saved_r1_streams = '1 × 48kHz'
|
||||
elif saved_rate == 24000:
|
||||
saved_r1_streams = '2 × 24kHz'
|
||||
elif saved_rate == 16000:
|
||||
saved_r1_streams = '3 × 16kHz'
|
||||
default_r1_idx = r1_stream_options.index(saved_r1_streams) if saved_r1_streams in r1_stream_options else 0
|
||||
|
||||
if dante_stereo_enabled:
|
||||
@@ -822,15 +952,25 @@ else:
|
||||
r1_available_qualities = []
|
||||
for quality in ["High (48kHz)", "Good (32kHz)", "Medium (24kHz)", "Fair (16kHz)"]:
|
||||
# Check if this quality is equal to or lower than the max
|
||||
if (r1_max_quality == "High (48kHz)" or
|
||||
if (r1_max_quality == "High (48kHz)" or
|
||||
(r1_max_quality == "Medium (24kHz)" and quality in ["Medium (24kHz)", "Fair (16kHz)"]) or
|
||||
(r1_max_quality == "Fair (16kHz)" and quality == "Fair (16kHz)")):
|
||||
r1_available_qualities.append(quality)
|
||||
|
||||
saved_r1_quality = saved_r1_config.get('radio_quality', r1_max_quality)
|
||||
|
||||
# Map saved sampling rate to quality label
|
||||
saved_r1_quality = r1_max_quality
|
||||
saved_rate = saved_settings.get('auracast_sampling_rate_hz')
|
||||
if saved_rate == 48000:
|
||||
saved_r1_quality = "High (48kHz)"
|
||||
elif saved_rate == 32000:
|
||||
saved_r1_quality = "Good (32kHz)"
|
||||
elif saved_rate == 24000:
|
||||
saved_r1_quality = "Medium (24kHz)"
|
||||
elif saved_rate == 16000:
|
||||
saved_r1_quality = "Fair (16kHz)"
|
||||
if saved_r1_quality not in r1_available_qualities:
|
||||
saved_r1_quality = r1_max_quality
|
||||
|
||||
|
||||
r1_radio_quality = st.selectbox(
|
||||
"Stream Quality (Radio 1)",
|
||||
r1_available_qualities,
|
||||
@@ -838,29 +978,29 @@ else:
|
||||
disabled=is_streaming,
|
||||
help=f"Select stream quality for Radio 1. Maximum quality based on configuration: {r1_max_quality}"
|
||||
)
|
||||
|
||||
|
||||
# Radio-level settings for Radio 1
|
||||
# First row: Assistive listening, immediate rendering, presentation delay, QoS
|
||||
col_r1_flags1, col_r1_flags2, col_r1_pdelay, col_r1_qos = st.columns([1, 1, 0.7, 0.6], gap="small")
|
||||
|
||||
|
||||
with col_r1_flags1:
|
||||
r1_assisted_listening = st.checkbox(
|
||||
"Assistive (R1)",
|
||||
value=bool(saved_r1_config.get('assisted_listening', False)),
|
||||
value=bool(saved_settings.get('assisted_listening_stream', False)),
|
||||
disabled=is_streaming,
|
||||
help="Assistive listening stream"
|
||||
)
|
||||
|
||||
|
||||
with col_r1_flags2:
|
||||
r1_immediate_rendering = st.checkbox(
|
||||
"Immediate (R1)",
|
||||
value=bool(saved_r1_config.get('immediate_rendering', False)),
|
||||
value=bool(saved_settings.get('immediate_rendering', False)),
|
||||
disabled=is_streaming,
|
||||
help="Ignore presentation delay"
|
||||
)
|
||||
|
||||
with col_r1_pdelay:
|
||||
default_pdelay = int(saved_r1_config.get('presentation_delay_us', 40000) or 40000)
|
||||
default_pdelay = int(saved_settings.get('presentation_delay_us', 40000) or 40000)
|
||||
default_pdelay_ms = max(10, min(200, default_pdelay // 1000))
|
||||
r1_presentation_delay_ms = st.number_input(
|
||||
"Delay (ms, R1)",
|
||||
@@ -868,10 +1008,10 @@ else:
|
||||
disabled=is_streaming,
|
||||
help="Presentation delay for Radio 1"
|
||||
)
|
||||
|
||||
|
||||
with col_r1_qos:
|
||||
qos_options = list(QOS_PRESET_MAP.keys())
|
||||
saved_qos = saved_r1_config.get('qos_preset', 'Fast')
|
||||
saved_qos = saved_settings.get('qos_preset', 'Fast')
|
||||
default_qos_idx = qos_options.index(saved_qos) if saved_qos in qos_options else 0
|
||||
r1_qos_preset = st.selectbox(
|
||||
"QoS (R1)", options=qos_options, index=default_qos_idx,
|
||||
@@ -889,24 +1029,31 @@ else:
|
||||
if dante_stereo_enabled:
|
||||
# Stereo mode: single stream with combined L+R channels
|
||||
with st.expander("Stereo Stream - Radio 1", expanded=True):
|
||||
saved_streams = saved_r1_config.get('streams', [])
|
||||
saved_stream = saved_streams[0] if saved_streams else {}
|
||||
|
||||
# Read from flat settings structure
|
||||
channel_names = saved_settings.get('channel_names', [])
|
||||
program_infos = saved_settings.get('program_info', [])
|
||||
languages = saved_settings.get('languages', [])
|
||||
|
||||
saved_name = channel_names[0] if channel_names else 'Dante_Stereo'
|
||||
saved_program_info = program_infos[0] if program_infos else saved_name
|
||||
saved_language = languages[0] if languages else 'eng'
|
||||
saved_password = saved_settings.get('stream_password', '')
|
||||
|
||||
# First row: Channel name and password
|
||||
col_name, col_pwd = st.columns([2, 1])
|
||||
|
||||
|
||||
with col_name:
|
||||
stream_name = st.text_input(
|
||||
"Channel Name",
|
||||
value=saved_stream.get('name', 'Dante_Stereo'),
|
||||
value=saved_name,
|
||||
disabled=is_streaming,
|
||||
key="r1_stereo_name"
|
||||
)
|
||||
|
||||
|
||||
with col_pwd:
|
||||
stream_password = st.text_input(
|
||||
"Stream Password",
|
||||
value=saved_stream.get('stream_password', ''),
|
||||
value=saved_password,
|
||||
type="password",
|
||||
disabled=is_streaming,
|
||||
key="r1_stereo_password",
|
||||
@@ -915,19 +1062,19 @@ else:
|
||||
|
||||
# Second row: Program info and language
|
||||
col_prog, col_lang_code = st.columns([2, 1])
|
||||
|
||||
|
||||
with col_prog:
|
||||
program_info = st.text_input(
|
||||
"Program Info",
|
||||
value=saved_stream.get('program_info', 'Dante Stereo Broadcast'),
|
||||
value=saved_program_info,
|
||||
disabled=is_streaming,
|
||||
key="r1_stereo_program"
|
||||
)
|
||||
|
||||
|
||||
with col_lang_code:
|
||||
language = st.text_input(
|
||||
"Language",
|
||||
value=saved_stream.get('language', 'eng'),
|
||||
value=saved_language,
|
||||
disabled=is_streaming,
|
||||
key="r1_stereo_lang",
|
||||
help="ISO 639-3 language code"
|
||||
@@ -961,47 +1108,58 @@ else:
|
||||
})
|
||||
else:
|
||||
# Normal mono mode: multiple streams with individual channels
|
||||
# Read from flat settings structure
|
||||
channel_names = saved_settings.get('channel_names', [])
|
||||
program_infos = saved_settings.get('program_info', [])
|
||||
languages = saved_settings.get('languages', [])
|
||||
input_devices = saved_settings.get('input_devices', [])
|
||||
stream_passwords = saved_settings.get('stream_passwords', []) if 'stream_passwords' in saved_settings else []
|
||||
|
||||
for i in range(r1_num_streams):
|
||||
with st.expander(f"Stream {i+1} - Radio 1", expanded=True):
|
||||
saved_streams = saved_r1_config.get('streams', [])
|
||||
saved_stream = saved_streams[i] if i < len(saved_streams) else {}
|
||||
|
||||
# Get saved values from flat structure
|
||||
saved_name = channel_names[i] if i < len(channel_names) else f'Dante_R1_S{i+1}'
|
||||
saved_program_info = program_infos[i] if i < len(program_infos) else f'Dante Radio 1 Stream {i+1}'
|
||||
saved_language = languages[i] if i < len(languages) else 'eng'
|
||||
saved_password = stream_passwords[i] if i < len(stream_passwords) else ''
|
||||
saved_input_device = input_devices[i] if i < len(input_devices) else None
|
||||
|
||||
# First row: Channel name and language
|
||||
col_name, col_lang = st.columns([2, 1])
|
||||
|
||||
|
||||
with col_name:
|
||||
stream_name = st.text_input(
|
||||
f"Channel Name",
|
||||
value=saved_stream.get('name', f'Dante_R1_S{i+1}'),
|
||||
value=saved_name,
|
||||
disabled=is_streaming,
|
||||
key=f"r1_stream_{i}_name"
|
||||
)
|
||||
|
||||
|
||||
with col_lang:
|
||||
stream_password = st.text_input(
|
||||
f"Stream Password",
|
||||
value=saved_stream.get('stream_password', ''),
|
||||
value=saved_password,
|
||||
type="password",
|
||||
disabled=is_streaming,
|
||||
key=f"r1_stream_{i}_password",
|
||||
help="Optional: Set a broadcast code for this stream"
|
||||
)
|
||||
|
||||
|
||||
# Second row: Program info and language
|
||||
col_prog, col_lang_code = st.columns([2, 1])
|
||||
|
||||
|
||||
with col_prog:
|
||||
program_info = st.text_input(
|
||||
f"Program Info",
|
||||
value=saved_stream.get('program_info', f'Dante Radio 1 Stream {i+1}'),
|
||||
value=saved_program_info,
|
||||
disabled=is_streaming,
|
||||
key=f"r1_stream_{i}_program"
|
||||
)
|
||||
|
||||
|
||||
with col_lang_code:
|
||||
language = st.text_input(
|
||||
f"Language",
|
||||
value=saved_stream.get('language', 'eng'),
|
||||
value=saved_language,
|
||||
disabled=is_streaming,
|
||||
key=f"r1_stream_{i}_lang",
|
||||
help="ISO 639-3 language code"
|
||||
@@ -1013,10 +1171,10 @@ else:
|
||||
with col_device:
|
||||
# Session state key for persisting the selection
|
||||
device_session_key = f"r1_stream_{i}_device_saved"
|
||||
|
||||
|
||||
if not is_streaming and input_options:
|
||||
# Get default from session state first, then from saved settings
|
||||
default_input_name = st.session_state.get(device_session_key, saved_stream.get('input_device'))
|
||||
default_input_name = st.session_state.get(device_session_key, saved_input_device)
|
||||
default_input_label = None
|
||||
for label, name in option_name_map.items():
|
||||
if name == default_input_name:
|
||||
@@ -1024,7 +1182,7 @@ else:
|
||||
break
|
||||
if default_input_label not in input_options and input_options:
|
||||
default_input_label = input_options[0]
|
||||
|
||||
|
||||
selected_option = st.selectbox(
|
||||
f"Input Device",
|
||||
input_options,
|
||||
@@ -1037,7 +1195,7 @@ else:
|
||||
st.session_state[device_session_key] = input_device
|
||||
else:
|
||||
# When streaming, get the device from session state
|
||||
current_device = st.session_state.get(device_session_key, saved_stream.get('input_device', 'No device'))
|
||||
current_device = st.session_state.get(device_session_key, saved_input_device or 'No device')
|
||||
|
||||
# Convert internal name to display label
|
||||
display_label = current_device
|
||||
@@ -1066,26 +1224,45 @@ else:
|
||||
# --- Radio 2 Section ---
|
||||
with st.container(border=True):
|
||||
st.subheader("Radio 2")
|
||||
|
||||
|
||||
# Disable Radio 2 in stereo mode
|
||||
saved_r2_config = saved_settings.get('dante_radio2', {})
|
||||
secondary_settings = saved_settings.get('secondary', {})
|
||||
if dante_stereo_enabled:
|
||||
st.info("🎧 Radio 2 is automatically disabled in stereo mode")
|
||||
radio2_enabled = False
|
||||
else:
|
||||
# Enable/disable checkbox for Radio 2
|
||||
# Use saved settings or streaming state to determine default
|
||||
radio2_enabled_default = secondary_is_streaming
|
||||
# Check if secondary radio has saved settings (indicates it was enabled)
|
||||
if secondary_settings.get('auracast_sampling_rate_hz') or secondary_settings.get('channel_names'):
|
||||
radio2_enabled_default = True
|
||||
radio2_enabled = st.checkbox(
|
||||
"Enable Radio 2",
|
||||
value=radio2_enabled_default,
|
||||
disabled=is_streaming,
|
||||
help="Activate a second Dante radio with its own quality and timing settings."
|
||||
)
|
||||
|
||||
|
||||
if radio2_enabled:
|
||||
# Stream count dropdown for Radio 2
|
||||
r2_stream_options = r1_stream_options
|
||||
saved_r2_streams = saved_r2_config.get('stream_config', '1x48')
|
||||
# Infer stream configuration from saved secondary sampling rate
|
||||
saved_rate2 = secondary_settings.get('auracast_sampling_rate_hz')
|
||||
saved_r2_streams = '1 × 48kHz' # default
|
||||
if saved_rate2:
|
||||
if saved_rate2 == 48000:
|
||||
channel_names2 = secondary_settings.get('channel_names', [])
|
||||
if len(channel_names2) == 2:
|
||||
saved_r2_streams = '2 × 24kHz'
|
||||
elif len(channel_names2) == 3:
|
||||
saved_r2_streams = '3 × 16kHz'
|
||||
else:
|
||||
saved_r2_streams = '1 × 48kHz'
|
||||
elif saved_rate2 == 24000:
|
||||
saved_r2_streams = '2 × 24kHz'
|
||||
elif saved_rate2 == 16000:
|
||||
saved_r2_streams = '3 × 16kHz'
|
||||
default_r2_idx = r2_stream_options.index(saved_r2_streams) if saved_r2_streams in r2_stream_options else 0
|
||||
|
||||
r2_stream_config = st.selectbox(
|
||||
@@ -1107,11 +1284,20 @@ else:
|
||||
(r2_max_quality == "Medium (24kHz)" and quality in ["Medium (24kHz)", "Fair (16kHz)"]) or
|
||||
(r2_max_quality == "Fair (16kHz)" and quality == "Fair (16kHz)")):
|
||||
r2_available_qualities.append(quality)
|
||||
|
||||
saved_r2_quality = saved_r2_config.get('radio_quality', r2_max_quality)
|
||||
|
||||
# Map saved secondary sampling rate to quality label
|
||||
saved_r2_quality = r2_max_quality
|
||||
if saved_rate2 == 48000:
|
||||
saved_r2_quality = "High (48kHz)"
|
||||
elif saved_rate2 == 32000:
|
||||
saved_r2_quality = "Good (32kHz)"
|
||||
elif saved_rate2 == 24000:
|
||||
saved_r2_quality = "Medium (24kHz)"
|
||||
elif saved_rate2 == 16000:
|
||||
saved_r2_quality = "Fair (16kHz)"
|
||||
if saved_r2_quality not in r2_available_qualities:
|
||||
saved_r2_quality = r2_max_quality
|
||||
|
||||
|
||||
r2_radio_quality = st.selectbox(
|
||||
"Stream Quality (Radio 2)",
|
||||
r2_available_qualities,
|
||||
@@ -1119,29 +1305,28 @@ else:
|
||||
disabled=is_streaming,
|
||||
help=f"Select stream quality for Radio 2. Maximum quality based on configuration: {r2_max_quality}"
|
||||
)
|
||||
|
||||
|
||||
# Radio-level settings for Radio 2
|
||||
# First row: Assistive listening, immediate rendering, presentation delay, QoS
|
||||
col_r2_flags1, col_r2_flags2, col_r2_pdelay, col_r2_qos = st.columns([1, 1, 0.7, 0.6], gap="small")
|
||||
|
||||
|
||||
with col_r2_flags1:
|
||||
r2_assisted_listening = st.checkbox(
|
||||
"Assistive (R2)",
|
||||
value=bool(saved_r2_config.get('assisted_listening', False)),
|
||||
value=bool(secondary_settings.get('assisted_listening_stream', False)),
|
||||
disabled=is_streaming,
|
||||
help="Assistive listening stream"
|
||||
)
|
||||
|
||||
|
||||
with col_r2_flags2:
|
||||
r2_immediate_rendering = st.checkbox(
|
||||
"Immediate (R2)",
|
||||
value=bool(saved_r2_config.get('immediate_rendering', False)),
|
||||
value=bool(secondary_settings.get('immediate_rendering', False)),
|
||||
disabled=is_streaming,
|
||||
help="Ignore presentation delay"
|
||||
)
|
||||
|
||||
|
||||
with col_r2_pdelay:
|
||||
default_pdelay = int(saved_r2_config.get('presentation_delay_us', 40000) or 40000)
|
||||
default_pdelay = int(secondary_settings.get('presentation_delay_us', 40000) or 40000)
|
||||
default_pdelay_ms = max(10, min(200, default_pdelay // 1000))
|
||||
r2_presentation_delay_ms = st.number_input(
|
||||
"Delay (ms, R2)",
|
||||
@@ -1149,13 +1334,13 @@ else:
|
||||
disabled=is_streaming,
|
||||
help="Presentation delay for Radio 2"
|
||||
)
|
||||
|
||||
|
||||
with col_r2_qos:
|
||||
qos_options = list(QOS_PRESET_MAP.keys())
|
||||
saved_qos = saved_r2_config.get('qos_preset', 'Fast')
|
||||
default_qos_idx = qos_options.index(saved_qos) if saved_qos in qos_options else 0
|
||||
saved_qos = secondary_settings.get('qos_preset', 'Fast')
|
||||
default_qos_idx2 = qos_options.index(saved_qos) if saved_qos in qos_options else 0
|
||||
r2_qos_preset = st.selectbox(
|
||||
"QoS (R2)", options=qos_options, index=default_qos_idx,
|
||||
"QoS (R2)", options=qos_options, index=default_qos_idx2,
|
||||
disabled=is_streaming,
|
||||
help="Quality of Service preset for Radio 2"
|
||||
)
|
||||
@@ -1163,48 +1348,59 @@ else:
|
||||
# Per-stream configuration for Radio 2
|
||||
st.write("**Stream Configuration (Radio 2)**")
|
||||
r2_streams = []
|
||||
|
||||
|
||||
# Read from flat secondary settings structure
|
||||
channel_names2 = secondary_settings.get('channel_names', [])
|
||||
program_infos2 = secondary_settings.get('program_info', [])
|
||||
languages2 = secondary_settings.get('languages', [])
|
||||
input_devices2 = secondary_settings.get('input_devices', [])
|
||||
stream_passwords2 = secondary_settings.get('stream_passwords', []) if 'stream_passwords' in secondary_settings else []
|
||||
|
||||
for i in range(r2_num_streams):
|
||||
with st.expander(f"Stream {i+1} - Radio 2", expanded=True):
|
||||
saved_streams = saved_r2_config.get('streams', [])
|
||||
saved_stream = saved_streams[i] if i < len(saved_streams) else {}
|
||||
# Get saved values from flat secondary structure
|
||||
saved_name2 = channel_names2[i] if i < len(channel_names2) else f'Dante_R2_S{i+1}'
|
||||
saved_program_info2 = program_infos2[i] if i < len(program_infos2) else f'Dante Radio 2 Stream {i+1}'
|
||||
saved_language2 = languages2[i] if i < len(languages2) else 'eng'
|
||||
saved_password2 = stream_passwords2[i] if i < len(stream_passwords2) else ''
|
||||
saved_input_device2 = input_devices2[i] if i < len(input_devices2) else None
|
||||
|
||||
# First row: Channel name and password
|
||||
col_name, col_pwd = st.columns([2, 1])
|
||||
|
||||
|
||||
with col_name:
|
||||
stream_name = st.text_input(
|
||||
f"Channel Name",
|
||||
value=saved_stream.get('name', f'Dante_R2_S{i+1}'),
|
||||
value=saved_name2,
|
||||
disabled=is_streaming,
|
||||
key=f"r2_stream_{i}_name"
|
||||
)
|
||||
|
||||
|
||||
with col_pwd:
|
||||
stream_password = st.text_input(
|
||||
f"Stream Password",
|
||||
value=saved_stream.get('stream_password', ''),
|
||||
value=saved_password2,
|
||||
type="password",
|
||||
disabled=is_streaming,
|
||||
key=f"r2_stream_{i}_password",
|
||||
help="Optional: Set a broadcast code for this stream"
|
||||
)
|
||||
|
||||
|
||||
# Second row: Program info and language
|
||||
col_prog, col_lang = st.columns([2, 1])
|
||||
|
||||
|
||||
with col_prog:
|
||||
program_info = st.text_input(
|
||||
f"Program Info",
|
||||
value=saved_stream.get('program_info', f'Dante Radio 2 Stream {i+1}'),
|
||||
value=saved_program_info2,
|
||||
disabled=is_streaming,
|
||||
key=f"r2_stream_{i}_program"
|
||||
)
|
||||
|
||||
|
||||
with col_lang:
|
||||
language = st.text_input(
|
||||
f"Language",
|
||||
value=saved_stream.get('language', 'eng'),
|
||||
value=saved_language2,
|
||||
disabled=is_streaming,
|
||||
key=f"r2_stream_{i}_lang",
|
||||
help="ISO 639-3 language code"
|
||||
@@ -1216,10 +1412,10 @@ else:
|
||||
with col_device:
|
||||
# Session state key for persisting the selection
|
||||
device_session_key = f"r2_stream_{i}_device_saved"
|
||||
|
||||
|
||||
if not is_streaming and input_options:
|
||||
# Get default from session state first, then from saved settings
|
||||
default_input_name = st.session_state.get(device_session_key, saved_stream.get('input_device'))
|
||||
default_input_name = st.session_state.get(device_session_key, saved_input_device2)
|
||||
default_input_label = None
|
||||
for label, name in option_name_map.items():
|
||||
if name == default_input_name:
|
||||
@@ -1227,7 +1423,7 @@ else:
|
||||
break
|
||||
if default_input_label not in input_options and input_options:
|
||||
default_input_label = input_options[0]
|
||||
|
||||
|
||||
selected_option = st.selectbox(
|
||||
f"Input Device",
|
||||
input_options,
|
||||
@@ -1240,7 +1436,7 @@ else:
|
||||
st.session_state[device_session_key] = input_device
|
||||
else:
|
||||
# When streaming, get the device from session state
|
||||
current_device = st.session_state.get(device_session_key, saved_stream.get('input_device', 'No device'))
|
||||
current_device = st.session_state.get(device_session_key, saved_input_device2 or 'No device')
|
||||
|
||||
# Convert internal name to display label
|
||||
display_label = current_device
|
||||
@@ -1325,8 +1521,30 @@ else:
|
||||
|
||||
if audio_mode in ("USB", "Network"):
|
||||
# USB/Network: single set of controls shared with the single channel
|
||||
# Use saved settings if audio_mode matches, otherwise use defaults
|
||||
saved_audio_mode = saved_settings.get('audio_mode')
|
||||
if saved_audio_mode in ("USB", "Network"):
|
||||
# Map saved sampling rate to quality label
|
||||
saved_rate = saved_settings.get('auracast_sampling_rate_hz')
|
||||
if saved_rate == 48000:
|
||||
default_quality = "High (48kHz)"
|
||||
elif saved_rate == 32000:
|
||||
default_quality = "Good (32kHz)"
|
||||
elif saved_rate == 24000:
|
||||
default_quality = "Medium (24kHz)"
|
||||
elif saved_rate == 16000:
|
||||
default_quality = "Fair (16kHz)"
|
||||
else:
|
||||
default_quality = "Medium (24kHz)"
|
||||
saved_pwd = saved_settings.get('stream_password', '')
|
||||
else:
|
||||
# Use defaults when switching from another mode
|
||||
default_quality = "Medium (24kHz)" if "Medium (24kHz)" in quality_options else quality_options[0]
|
||||
saved_pwd = ''
|
||||
|
||||
quality_options = list(QUALITY_MAP.keys())
|
||||
default_quality = "Medium (24kHz)" if "Medium (24kHz)" in quality_options else quality_options[0]
|
||||
if default_quality not in quality_options:
|
||||
default_quality = quality_options[0]
|
||||
quality = st.selectbox(
|
||||
"Stream Quality (Sampling Rate)",
|
||||
quality_options,
|
||||
@@ -1337,7 +1555,7 @@ else:
|
||||
|
||||
stream_passwort = st.text_input(
|
||||
"Stream Passwort",
|
||||
value="",
|
||||
value=saved_pwd,
|
||||
type="password",
|
||||
disabled=is_streaming,
|
||||
help="Optional: Set a broadcast code to protect your stream. Leave empty for an open (uncoded) broadcast."
|
||||
@@ -1585,11 +1803,11 @@ if start_stream:
|
||||
immediate_rendering=bool(cfg['immediate_rendering']),
|
||||
presentation_delay_us=int(cfg['presentation_delay_ms'] * 1000),
|
||||
qos_config=QOS_PRESET_MAP[cfg['qos_preset']],
|
||||
analog_gain=cfg.get('analog_gain', 50),
|
||||
analog_gain_db_left=cfg.get('analog_gain_db_left', 0.0),
|
||||
analog_gain_db_right=cfg.get('analog_gain_db_right', 0.0),
|
||||
bigs=[
|
||||
auracast_config.AuracastBigConfig(
|
||||
id=cfg.get('id', 123456),
|
||||
code=(cfg['stream_passwort'].strip() or None),
|
||||
code=((cfg['stream_passwort'] or '').strip() or None),
|
||||
name=cfg['name'],
|
||||
program_info=cfg['program_info'],
|
||||
language=cfg['language'],
|
||||
@@ -1599,7 +1817,6 @@ if start_stream:
|
||||
sampling_frequency=q['rate'],
|
||||
octets_per_frame=q['octets'],
|
||||
num_bis=channels, # 1=mono, 2=stereo - this determines the behavior
|
||||
input_gain_db=float(cfg.get('software_boost_db', 0)),
|
||||
)
|
||||
],
|
||||
)
|
||||
@@ -1643,8 +1860,6 @@ if start_stream:
|
||||
if not stream.get('input_device'):
|
||||
continue
|
||||
|
||||
stream_id = radio_id * 1000 + i + 1 # Unique ID per stream
|
||||
|
||||
# Check if this specific stream uses stereo (dante_stereo_X_Y device)
|
||||
input_device = stream['input_device']
|
||||
stream_is_stereo = is_stereo_mode or input_device.startswith('dante_stereo_')
|
||||
@@ -1652,7 +1867,6 @@ if start_stream:
|
||||
num_channels = 2 if stream_is_stereo else 1
|
||||
|
||||
bigs.append(auracast_config.AuracastBigConfig(
|
||||
id=stream_id,
|
||||
code=(stream.get('stream_password', '').strip() or None),
|
||||
name=stream['name'],
|
||||
program_info=stream['program_info'],
|
||||
@@ -1755,7 +1969,21 @@ if is_started or is_stopped:
|
||||
# System expander (collapsed)
|
||||
############################
|
||||
with st.expander("System control", expanded=False):
|
||||
|
||||
|
||||
st.subheader("Status LED")
|
||||
led_enabled_current = bool(saved_settings.get("led_enabled", True))
|
||||
led_enabled = st.checkbox(
|
||||
"Blue LED on while transmitting",
|
||||
value=led_enabled_current,
|
||||
help="When enabled, the blue LED on GPIO pin 12 lights up while the stream is active."
|
||||
)
|
||||
if led_enabled != led_enabled_current:
|
||||
try:
|
||||
requests.post(f"{BACKEND_URL}/set_led_enabled", json={"led_enabled": led_enabled}, timeout=2)
|
||||
except Exception as e:
|
||||
st.error(f"Failed to update LED setting: {e}")
|
||||
st.rerun()
|
||||
|
||||
st.subheader("System temperatures")
|
||||
temp_col1, temp_col2, temp_col3 = st.columns([1, 1, 1])
|
||||
with temp_col1:
|
||||
|
||||
@@ -3,12 +3,14 @@ TODO: in the future the multicaster objects should run in their own threads or e
|
||||
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import logging as log
|
||||
import json
|
||||
from datetime import datetime
|
||||
import asyncio
|
||||
import random
|
||||
import subprocess
|
||||
import threading
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from fastapi import FastAPI, HTTPException
|
||||
@@ -26,6 +28,57 @@ from auracast.utils.sounddevice_utils import (
|
||||
)
|
||||
|
||||
load_dotenv()
|
||||
|
||||
# Blue LED on GPIO pin 12 (BCM) – turns on while transmitting
|
||||
LED_PIN = 12
|
||||
try:
|
||||
import RPi.GPIO as _GPIO
|
||||
_GPIO.setmode(_GPIO.BCM)
|
||||
_GPIO.setup(LED_PIN, _GPIO.OUT)
|
||||
_GPIO_AVAILABLE = True
|
||||
except Exception:
|
||||
_GPIO_AVAILABLE = False
|
||||
_GPIO = None # type: ignore
|
||||
|
||||
_LED_ENABLED: bool = True # toggled via /set_led_enabled
|
||||
_LED_SETTINGS_FILE = os.path.join(os.path.dirname(__file__), 'led_settings.json')
|
||||
|
||||
def _load_led_settings() -> None:
|
||||
global _LED_ENABLED
|
||||
try:
|
||||
if os.path.exists(_LED_SETTINGS_FILE):
|
||||
with open(_LED_SETTINGS_FILE, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
_LED_ENABLED = bool(data.get('led_enabled', True))
|
||||
except Exception:
|
||||
_LED_ENABLED = True
|
||||
|
||||
def _save_led_settings() -> None:
|
||||
try:
|
||||
os.makedirs(os.path.dirname(_LED_SETTINGS_FILE), exist_ok=True)
|
||||
with open(_LED_SETTINGS_FILE, 'w', encoding='utf-8') as f:
|
||||
json.dump({'led_enabled': _LED_ENABLED}, f)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _led_on():
|
||||
if _GPIO_AVAILABLE and _LED_ENABLED:
|
||||
try:
|
||||
_GPIO.output(LED_PIN, _GPIO.LOW)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _led_off():
|
||||
if _GPIO_AVAILABLE:
|
||||
try:
|
||||
_GPIO.output(LED_PIN, _GPIO.HIGH)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# Configure bumble debug logging
|
||||
# log.getLogger('bumble').setLevel(log.DEBUG)
|
||||
|
||||
# make sure pipewire sets latency
|
||||
# Primary and secondary persisted settings files
|
||||
STREAM_SETTINGS_FILE1 = os.path.join(os.path.dirname(__file__), 'stream_settings.json')
|
||||
@@ -133,6 +186,10 @@ def save_settings(persisted: dict, secondary: bool = False) -> None:
|
||||
def gen_random_add() -> str:
|
||||
return ':'.join(['%02X' % random.randint(0, 255) for _ in range(6)])
|
||||
|
||||
def gen_random_broadcast_id() -> int:
|
||||
"""Generate a random 24-bit Broadcast ID (1..0xFFFFFF)."""
|
||||
return random.randint(1, 0xFFFFFF)
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
# Allow CORS for frontend on localhost
|
||||
@@ -152,6 +209,28 @@ multicaster1: multicast_control.Multicaster | None = None
|
||||
multicaster2: multicast_control.Multicaster | None = None
|
||||
_stream_lock = asyncio.Lock() # serialize initialize/stop_audio on API side
|
||||
|
||||
# BLE / audio event loop – set in __main__ before uvicorn starts.
|
||||
# All coroutines that touch Bumble objects or the audio pipeline MUST run
|
||||
# on this loop. HTTP handlers call _on_ble_loop() to cross into it.
|
||||
_ble_loop: asyncio.AbstractEventLoop | None = None
|
||||
|
||||
|
||||
async def _on_ble_loop(coro):
|
||||
"""Submit *coro* to the BLE event loop and await the result.
|
||||
|
||||
Called from uvicorn's event loop. Bridges HTTP handler coroutines into
|
||||
the isolated BLE loop so that serial I/O (serial_asyncio / HCI) and the
|
||||
audio pipeline are never preempted by HTTP accept/read/write callbacks.
|
||||
|
||||
asyncio.run_coroutine_threadsafe() schedules the coroutine on _ble_loop
|
||||
(thread-safe), returning a concurrent.futures.Future.
|
||||
asyncio.wrap_future() adapts that into an asyncio.Future so the caller
|
||||
can simply `await` it inside uvicorn's loop.
|
||||
"""
|
||||
assert _ble_loop is not None, "BLE loop not yet initialised"
|
||||
future = asyncio.run_coroutine_threadsafe(coro, _ble_loop)
|
||||
return await asyncio.wrap_future(future)
|
||||
|
||||
|
||||
async def _init_i2c_on_startup() -> None:
|
||||
# Ensure i2c-dev kernel module is loaded (required for /dev/i2c-* access)
|
||||
@@ -238,16 +317,18 @@ async def _init_i2c_on_startup() -> None:
|
||||
log.warning("Exception running i2cget (%s): %s", " ".join(read_cmd), e, exc_info=True)
|
||||
|
||||
|
||||
async def _set_adc_level(gain_percent: int = 50) -> None:
|
||||
"""Set ADC mixer level.
|
||||
async def _set_adc_level(gain_db_left: float = 0.0, gain_db_right: float = 0.0) -> None:
|
||||
"""Set ADC mixer gain in dB for left and right channels independently.
|
||||
|
||||
Runs: amixer -c 1 set 'ADC' x%
|
||||
Runs: amixer -c 2 sset ADC {gain_db_left}dB,{gain_db_right}dB
|
||||
|
||||
Args:
|
||||
gain_percent: Gain level 10-60, default 50 (above 60% causes clipping/silence)
|
||||
gain_db_left: Left channel gain in dB (-12 to 18), default 0
|
||||
gain_db_right: Right channel gain in dB (-12 to 18), default 0
|
||||
"""
|
||||
gain_percent = max(10, min(60, gain_percent))
|
||||
cmd = ["amixer", "-c", "1", "set", "ADC", f"{gain_percent}%"]
|
||||
gain_db_left = max(-12.0, min(18.0, gain_db_left))
|
||||
gain_db_right = max(-12.0, min(18.0, gain_db_right))
|
||||
cmd = ["amixer", "-c", "2", "sset", "ADC", "--", f"{int(gain_db_left)}dB,{int(gain_db_right)}dB"]
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
@@ -256,15 +337,46 @@ async def _set_adc_level(gain_percent: int = 50) -> None:
|
||||
)
|
||||
stdout, stderr = await proc.communicate()
|
||||
if proc.returncode != 0:
|
||||
log.warning(
|
||||
log.error(
|
||||
"amixer ADC level command failed (rc=%s): %s",
|
||||
proc.returncode,
|
||||
(stderr or b"" ).decode(errors="ignore").strip(),
|
||||
)
|
||||
else:
|
||||
log.info("amixer ADC level set successfully: %s", (stdout or b"" ).decode(errors="ignore").strip())
|
||||
read_proc = await asyncio.create_subprocess_exec(
|
||||
"amixer", "-c", "2", "sget", "ADC",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
read_stdout, read_stderr = await read_proc.communicate()
|
||||
if read_proc.returncode != 0:
|
||||
log.error(
|
||||
"amixer ADC sget failed (rc=%s): %s",
|
||||
read_proc.returncode,
|
||||
(read_stderr or b"").decode(errors="ignore").strip(),
|
||||
)
|
||||
else:
|
||||
sget_output = (read_stdout or b"").decode(errors="ignore")
|
||||
actual = {}
|
||||
for line in sget_output.splitlines():
|
||||
for ch_key, ch_name in (("left", "Front Left"), ("right", "Front Right")):
|
||||
if ch_name in line:
|
||||
m = re.search(r'\[(-?\d+(?:\.\d+)?)dB\]', line)
|
||||
if m:
|
||||
actual[ch_key] = round(float(m.group(1)))
|
||||
expected_left = int(gain_db_left)
|
||||
expected_right = int(gain_db_right)
|
||||
if actual.get("left") != expected_left or actual.get("right") != expected_right:
|
||||
mismatch = (
|
||||
f"ADC level mismatch after set: expected L={expected_left}dB R={expected_right}dB, "
|
||||
f"got L={actual.get('left')}dB R={actual.get('right')}dB"
|
||||
)
|
||||
log.error(mismatch)
|
||||
else:
|
||||
log.info("ADC level set successfully: L=%sdB R=%sdB", expected_left, expected_right)
|
||||
except Exception as e:
|
||||
log.warning("Exception running amixer ADC level command: %s", e, exc_info=True)
|
||||
log.error("Exception running amixer ADC level command: %s", e, exc_info=True)
|
||||
|
||||
|
||||
async def _stop_all() -> bool:
|
||||
@@ -284,6 +396,7 @@ async def _stop_all() -> bool:
|
||||
was_running = True
|
||||
finally:
|
||||
multicaster2 = None
|
||||
_led_off()
|
||||
return was_running
|
||||
|
||||
async def _status_primary() -> dict:
|
||||
@@ -332,6 +445,11 @@ async def init_radio(transport: str, conf: auracast_config.AuracastConfigGroup,
|
||||
first_source = conf.bigs[0].audio_source if conf.bigs else ''
|
||||
input_device_name = None
|
||||
audio_mode_persist = 'Demo'
|
||||
# Capture original per-BIG device names before transformation
|
||||
original_input_devices = [
|
||||
big.audio_source.split(':', 1)[1] if (isinstance(big.audio_source, str) and big.audio_source.startswith('device:')) else None
|
||||
for big in conf.bigs
|
||||
]
|
||||
if any(isinstance(b.audio_source, str) and b.audio_source.startswith('device:') for b in conf.bigs):
|
||||
if isinstance(first_source, str) and first_source.startswith('device:'):
|
||||
input_device_name = first_source.split(':', 1)[1] if ':' in first_source else None
|
||||
@@ -343,8 +461,9 @@ async def init_radio(transport: str, conf: auracast_config.AuracastConfigGroup,
|
||||
if input_device_name in ('ch1', 'ch2'):
|
||||
audio_mode_persist = 'Analog'
|
||||
# Set ADC gain level for analog mode
|
||||
analog_gain = getattr(conf, 'analog_gain', 50)
|
||||
await _set_adc_level(analog_gain)
|
||||
analog_gain_db_left = getattr(conf, 'analog_gain_db_left', 0.0)
|
||||
analog_gain_db_right = getattr(conf, 'analog_gain_db_right', 0.0)
|
||||
await _set_adc_level(analog_gain_db_left, analog_gain_db_right)
|
||||
elif input_device_name in dante_channels:
|
||||
audio_mode_persist = 'Network - Dante'
|
||||
else:
|
||||
@@ -387,18 +506,15 @@ async def init_radio(transport: str, conf: auracast_config.AuracastConfigGroup,
|
||||
if is_stereo and sel == 'ch1':
|
||||
# Stereo mode: use ALSA directly to capture both channels from hardware
|
||||
# ch1=left (channel 0), ch2=right (channel 1)
|
||||
big.audio_source = 'alsa:hw:CARD=i2s,DEV=0'
|
||||
big.audio_source = 'device:hw:2'
|
||||
big.input_format = f"int16le,{hardware_capture_rate},2"
|
||||
log.info("Configured analog stereo input: using ALSA hw:CARD=i2s,DEV=0 with ch1=left, ch2=right")
|
||||
elif is_stereo and sel == 'ch2':
|
||||
# Skip ch2 in stereo mode as it's already captured as part of stereo pair
|
||||
continue
|
||||
else:
|
||||
# Mono mode: individual channel capture
|
||||
device_index = resolve_input_device_index(sel)
|
||||
if device_index is None:
|
||||
raise HTTPException(status_code=400, detail=f"Audio device '{sel}' not found.")
|
||||
big.audio_source = f'device:{device_index}'
|
||||
# Mono mode: use dsnoop virtual device directly (ch1=left, ch2=right)
|
||||
big.audio_source = f'device:{sel}'
|
||||
big.input_format = f"int16le,{hardware_capture_rate},1"
|
||||
continue
|
||||
|
||||
@@ -454,10 +570,12 @@ async def init_radio(transport: str, conf: auracast_config.AuracastConfigGroup,
|
||||
|
||||
conf.qos_config.max_transport_latency_ms = int(conf.qos_config.number_of_retransmissions) * 10 + 3
|
||||
|
||||
# Only generate a new random_address if the BIG is still at the model default.
|
||||
# Generate fresh random_address and broadcast ID for any BIG still at model defaults.
|
||||
for big in conf.bigs:
|
||||
if not getattr(big, 'random_address', None) or big.random_address == DEFAULT_RANDOM_ADDRESS:
|
||||
big.random_address = gen_random_add()
|
||||
if big.id == DEFAULT_BIG_ID:
|
||||
big.id = gen_random_broadcast_id()
|
||||
|
||||
# Log the final, fully-updated configuration just before creating the Multicaster
|
||||
log.info('Final multicaster config (transport=%s):\n %s', transport, conf.model_dump_json(indent=2))
|
||||
@@ -468,6 +586,7 @@ async def init_radio(transport: str, conf: auracast_config.AuracastConfigGroup,
|
||||
auto_started = False
|
||||
if any(isinstance(big.audio_source, str) and (big.audio_source.startswith("device:") or big.audio_source.startswith("alsa:") or big.audio_source.startswith("file:")) for big in conf.bigs):
|
||||
await mc.start_streaming()
|
||||
_led_on()
|
||||
auto_started = True
|
||||
|
||||
demo_count = sum(1 for big in conf.bigs if isinstance(big.audio_source, str) and big.audio_source.startswith('file:'))
|
||||
@@ -483,6 +602,7 @@ async def init_radio(transport: str, conf: auracast_config.AuracastConfigGroup,
|
||||
'languages': [big.language for big in conf.bigs],
|
||||
'audio_mode': audio_mode_persist,
|
||||
'input_device': input_device_name,
|
||||
'input_devices': original_input_devices,
|
||||
'program_info': [getattr(big, 'program_info', None) for big in conf.bigs],
|
||||
'gain': [getattr(big, 'input_gain', 1.0) for big in conf.bigs],
|
||||
'auracast_sampling_rate_hz': conf.auracast_sampling_rate_hz,
|
||||
@@ -492,8 +612,8 @@ async def init_radio(transport: str, conf: auracast_config.AuracastConfigGroup,
|
||||
'immediate_rendering': getattr(conf, 'immediate_rendering', False),
|
||||
'assisted_listening_stream': getattr(conf, 'assisted_listening_stream', False),
|
||||
'analog_stereo_mode': getattr(conf.bigs[0], 'analog_stereo_mode', False) if conf.bigs else False,
|
||||
'analog_gain': getattr(conf, 'analog_gain', 50),
|
||||
'software_boost_db': getattr(conf.bigs[0], 'input_gain_db', 0.0) if conf.bigs else 0.0,
|
||||
'analog_gain_db_left': getattr(conf, 'analog_gain_db_left', 0.0),
|
||||
'analog_gain_db_right': getattr(conf, 'analog_gain_db_right', 0.0),
|
||||
'stream_password': (conf.bigs[0].code if conf.bigs and getattr(conf.bigs[0], 'code', None) else None),
|
||||
'big_ids': [getattr(big, 'id', DEFAULT_BIG_ID) for big in conf.bigs],
|
||||
'big_random_addresses': [getattr(big, 'random_address', DEFAULT_RANDOM_ADDRESS) for big in conf.bigs],
|
||||
@@ -511,7 +631,10 @@ async def init_radio(transport: str, conf: auracast_config.AuracastConfigGroup,
|
||||
|
||||
@app.post("/init")
|
||||
async def initialize(conf: auracast_config.AuracastConfigGroup):
|
||||
"""Initializes the primary broadcaster on the streamer thread."""
|
||||
"""Initializes the primary broadcaster on the BLE loop."""
|
||||
return await _on_ble_loop(_initialize_impl(conf))
|
||||
|
||||
async def _initialize_impl(conf: auracast_config.AuracastConfigGroup):
|
||||
async with _stream_lock:
|
||||
global multicaster1, global_config_group
|
||||
mc, persisted = await init_radio(TRANSPORT1, conf, multicaster1)
|
||||
@@ -521,16 +644,33 @@ async def initialize(conf: auracast_config.AuracastConfigGroup):
|
||||
|
||||
@app.post("/init2")
|
||||
async def initialize2(conf: auracast_config.AuracastConfigGroup):
|
||||
"""Initializes the secondary broadcaster on the streamer thread."""
|
||||
"""Initializes the secondary broadcaster on the BLE loop."""
|
||||
return await _on_ble_loop(_initialize2_impl(conf))
|
||||
|
||||
async def _initialize2_impl(conf: auracast_config.AuracastConfigGroup):
|
||||
async with _stream_lock:
|
||||
global multicaster2
|
||||
mc, persisted = await init_radio(TRANSPORT2, conf, multicaster2)
|
||||
multicaster2 = mc
|
||||
save_settings(persisted, secondary=True)
|
||||
|
||||
@app.post("/set_led_enabled")
|
||||
async def set_led_enabled(body: dict):
|
||||
"""Enable or disable the blue status LED. Persisted across restarts."""
|
||||
global _LED_ENABLED
|
||||
_LED_ENABLED = bool(body.get("led_enabled", True))
|
||||
_save_led_settings()
|
||||
if not _LED_ENABLED:
|
||||
_led_off()
|
||||
return {"led_enabled": _LED_ENABLED}
|
||||
|
||||
@app.post("/stop_audio")
|
||||
async def stop_audio():
|
||||
"""Stops streaming on both multicaster1 and multicaster2 (worker thread)."""
|
||||
"""Stops streaming on both multicasters via the BLE loop."""
|
||||
return await _on_ble_loop(_stop_audio_impl())
|
||||
|
||||
async def _stop_audio_impl():
|
||||
"""Runs on BLE loop: stops all streamers and persists is_streaming=False."""
|
||||
try:
|
||||
was_running = await _stop_all()
|
||||
|
||||
@@ -556,11 +696,33 @@ async def stop_audio():
|
||||
log.error("Exception in /stop_audio: %s", traceback.format_exc())
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@app.post("/adc_gain")
|
||||
async def set_adc_gain(payload: dict):
|
||||
"""Set ADC gain in dB for left and right channels without restarting the stream.
|
||||
|
||||
Body: {"gain_db_left": float, "gain_db_right": float}
|
||||
"""
|
||||
try:
|
||||
gain_db_left = float(payload.get("gain_db_left", 0.0))
|
||||
gain_db_right = float(payload.get("gain_db_right", 0.0))
|
||||
await _set_adc_level(gain_db_left, gain_db_right)
|
||||
# Persist the new values so they survive a restart
|
||||
for load_fn, save_fn in [(load_stream_settings, save_stream_settings), (load_stream_settings2, save_stream_settings2)]:
|
||||
s = load_fn() or {}
|
||||
if s:
|
||||
s['analog_gain_db_left'] = gain_db_left
|
||||
s['analog_gain_db_right'] = gain_db_right
|
||||
save_fn(s)
|
||||
return {"status": "ok", "gain_db_left": gain_db_left, "gain_db_right": gain_db_right}
|
||||
except Exception as e:
|
||||
log.error("Exception in /adc_gain: %s", traceback.format_exc())
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@app.post("/stream_lc3")
|
||||
async def send_audio(audio_data: dict[str, str]):
|
||||
"""Sends a block of pre-coded LC3 audio via the worker."""
|
||||
"""Sends a block of pre-coded LC3 audio via the BLE loop."""
|
||||
try:
|
||||
await _stream_lc3(audio_data, list(global_config_group.bigs))
|
||||
await _on_ble_loop(_stream_lc3(audio_data, list(global_config_group.bigs)))
|
||||
return {"status": "audio_sent"}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
@@ -591,6 +753,7 @@ async def get_status():
|
||||
secondary.update(secondary_persisted)
|
||||
status["secondary"] = secondary
|
||||
status["secondary_is_streaming"] = bool(secondary.get("is_streaming", False))
|
||||
status["led_enabled"] = _LED_ENABLED
|
||||
|
||||
return status
|
||||
|
||||
@@ -751,7 +914,6 @@ async def _autostart_from_settings():
|
||||
iso_que_len=1,
|
||||
sampling_frequency=rate,
|
||||
octets_per_frame=octets,
|
||||
input_gain_db=float(settings.get('software_boost_db', 0)),
|
||||
)
|
||||
]
|
||||
conf = auracast_config.AuracastConfigGroup(
|
||||
@@ -761,7 +923,8 @@ async def _autostart_from_settings():
|
||||
immediate_rendering=immediate_rendering,
|
||||
assisted_listening_stream=assisted_listening_stream,
|
||||
presentation_delay_us=pres_delay if pres_delay is not None else 40000,
|
||||
analog_gain=settings.get('analog_gain', 50),
|
||||
analog_gain_db_left=settings.get('analog_gain_db_left', 0.0),
|
||||
analog_gain_db_right=settings.get('analog_gain_db_right', 0.0),
|
||||
bigs=bigs,
|
||||
)
|
||||
# Set num_bis for stereo mode if needed
|
||||
@@ -906,7 +1069,6 @@ async def _autostart_from_settings():
|
||||
iso_que_len=1,
|
||||
sampling_frequency=rate,
|
||||
octets_per_frame=octets,
|
||||
input_gain_db=float(settings.get('software_boost_db', 0)),
|
||||
)
|
||||
]
|
||||
conf = auracast_config.AuracastConfigGroup(
|
||||
@@ -916,7 +1078,8 @@ async def _autostart_from_settings():
|
||||
immediate_rendering=immediate_rendering,
|
||||
assisted_listening_stream=assisted_listening_stream,
|
||||
presentation_delay_us=pres_delay if pres_delay is not None else 40000,
|
||||
analog_gain=settings.get('analog_gain', 50),
|
||||
analog_gain_db_left=settings.get('analog_gain_db_left', 0.0),
|
||||
analog_gain_db_right=settings.get('analog_gain_db_right', 0.0),
|
||||
bigs=bigs,
|
||||
)
|
||||
conf.qos_config = QOS_PRESET_MAP.get(saved_qos_preset, QOS_PRESET_MAP["Fast"])
|
||||
@@ -934,13 +1097,27 @@ async def _autostart_from_settings():
|
||||
await do_primary()
|
||||
await do_secondary()
|
||||
|
||||
async def _ble_startup():
|
||||
"""I2C init, ADC level reset, and autostart task scheduling on the BLE loop.
|
||||
|
||||
Bridged from _startup_autostart_event() so that these async subprocess
|
||||
calls and the long-lived autostart coroutine all run on _ble_loop, never
|
||||
on uvicorn's HTTP loop.
|
||||
"""
|
||||
await _init_i2c_on_startup()
|
||||
await _set_adc_level(0.0, 0.0)
|
||||
log.info("[STARTUP] Scheduling autostart task on BLE loop")
|
||||
asyncio.create_task(_autostart_from_settings())
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
async def _startup_autostart_event():
|
||||
# Spawn the autostart task without blocking startup
|
||||
log.info("[STARTUP] Auracast multicast server startup: initializing settings cache, I2C, and PipeWire cache")
|
||||
_led_off()
|
||||
|
||||
# Run install_asoundconf.sh script
|
||||
script_path = os.path.join(os.path.dirname(__file__), '..', 'misc', 'install_asoundconf.sh')
|
||||
script_path = os.path.join(os.path.dirname(__file__), '..', '..', 'misc', 'install_asoundconf.sh')
|
||||
try:
|
||||
log.info("[STARTUP] Running install_asoundconf.sh script")
|
||||
result = subprocess.run(['bash', script_path], capture_output=True, text=True, check=True)
|
||||
@@ -951,13 +1128,13 @@ async def _startup_autostart_event():
|
||||
log.error(f"[STARTUP] Error running install_asoundconf.sh: {str(e)}")
|
||||
|
||||
# Hydrate settings cache once to avoid disk I/O during /status
|
||||
_load_led_settings()
|
||||
_init_settings_cache_from_disk()
|
||||
await _init_i2c_on_startup()
|
||||
# Ensure ADC mixer level is set at startup (default 50%)
|
||||
await _set_adc_level(50)
|
||||
refresh_pw_cache()
|
||||
log.info("[STARTUP] Scheduling autostart task")
|
||||
asyncio.create_task(_autostart_from_settings())
|
||||
# I2C init, ADC setup and the autostart task must run on the BLE loop so
|
||||
# they share the same event loop as the Bumble HCI transport.
|
||||
log.info("[STARTUP] Bridging I2C init and autostart to BLE loop")
|
||||
asyncio.run_coroutine_threadsafe(_ble_startup(), _ble_loop)
|
||||
|
||||
@app.get("/audio_inputs_pw_usb")
|
||||
async def audio_inputs_pw_usb():
|
||||
@@ -1028,6 +1205,9 @@ async def refresh_audio_devices():
|
||||
@app.post("/shutdown")
|
||||
async def shutdown():
|
||||
"""Stops broadcasting and releases all audio/Bluetooth resources."""
|
||||
return await _on_ble_loop(_shutdown_impl())
|
||||
|
||||
async def _shutdown_impl():
|
||||
try:
|
||||
await _stop_all()
|
||||
return {"status": "stopped"}
|
||||
@@ -1040,6 +1220,9 @@ async def system_reboot():
|
||||
|
||||
Requires the service user to have passwordless sudo permissions to run 'reboot'.
|
||||
"""
|
||||
return await _on_ble_loop(_system_reboot_impl())
|
||||
|
||||
async def _system_reboot_impl():
|
||||
try:
|
||||
# Best-effort: stop any active streaming cleanly WITHOUT persisting state
|
||||
try:
|
||||
@@ -1063,47 +1246,27 @@ async def system_reboot():
|
||||
|
||||
@app.post("/restart_dep")
|
||||
async def restart_dep():
|
||||
"""Restart DEP by running dep.sh stop then dep.sh start in the dep directory.
|
||||
"""Restart DEP via systemctl restart dep.service.
|
||||
|
||||
Requires the service user to have passwordless sudo permissions to run dep.sh.
|
||||
Requires the service user to have passwordless sudo permissions for systemctl.
|
||||
"""
|
||||
try:
|
||||
# Get the dep directory path (dep.sh is in dante_package subdirectory)
|
||||
dep_dir = os.path.join(os.path.dirname(__file__), '..', '..', 'dep', 'dante_package')
|
||||
|
||||
# Run dep.sh stop first
|
||||
log.info("Stopping DEP...")
|
||||
stop_process = await asyncio.create_subprocess_exec(
|
||||
"sudo", "bash", "dep.sh", "stop",
|
||||
cwd=dep_dir,
|
||||
log.info("Restarting DEP via systemctl...")
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"sudo", "systemctl", "restart", "dep.service",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE
|
||||
)
|
||||
stop_stdout, stop_stderr = await stop_process.communicate()
|
||||
|
||||
if stop_process.returncode != 0:
|
||||
error_msg = stop_stderr.decode() if stop_stderr else "Unknown error"
|
||||
log.error(f"Failed to stop DEP: {error_msg}")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to stop DEP: {error_msg}")
|
||||
|
||||
# Run dep.sh start after stop succeeds
|
||||
log.info("Starting DEP...")
|
||||
start_process = await asyncio.create_subprocess_exec(
|
||||
"sudo", "bash", "dep.sh", "start",
|
||||
cwd=dep_dir,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE
|
||||
)
|
||||
start_stdout, start_stderr = await start_process.communicate()
|
||||
|
||||
if start_process.returncode == 0:
|
||||
stdout, stderr = await proc.communicate()
|
||||
|
||||
if proc.returncode == 0:
|
||||
log.info("DEP restarted successfully")
|
||||
return {"status": "success", "message": "DEP restarted successfully"}
|
||||
else:
|
||||
error_msg = start_stderr.decode() if start_stderr else "Unknown error"
|
||||
log.error(f"Failed to start DEP: {error_msg}")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to start DEP: {error_msg}")
|
||||
|
||||
error_msg = stderr.decode() if stderr else "Unknown error"
|
||||
log.error(f"Failed to restart DEP: {error_msg}")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to restart DEP: {error_msg}")
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
@@ -1196,6 +1359,9 @@ async def check_update():
|
||||
@app.post("/system_update")
|
||||
async def system_update():
|
||||
"""Update application: git pull main branch (latest tag), poetry install, restart services."""
|
||||
return await _on_ble_loop(_system_update_impl())
|
||||
|
||||
async def _system_update_impl():
|
||||
try:
|
||||
# Best-effort: stop any active streaming cleanly
|
||||
try:
|
||||
@@ -1245,26 +1411,12 @@ async def system_update():
|
||||
log.error("git checkout failed: %s", stderr.decode())
|
||||
raise HTTPException(status_code=500, detail=f"git checkout failed: {stderr.decode()}")
|
||||
|
||||
# 2. Run poetry install (use full path as poetry is in user's ~/.local/bin)
|
||||
poetry_path = os.path.expanduser("~/.local/bin/poetry")
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
poetry_path, "install",
|
||||
cwd=project_root,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE
|
||||
)
|
||||
stdout, stderr = await proc.communicate()
|
||||
if proc.returncode != 0:
|
||||
log.error("poetry install failed: %s", stderr.decode())
|
||||
raise HTTPException(status_code=500, detail=f"poetry install failed: {stderr.decode()}")
|
||||
|
||||
# 3. Restart services via the update script
|
||||
update_script = os.path.join(project_root, 'src', 'service', 'update_and_run_server_and_frontend.sh')
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
# 2. Hand off remaining work to the (now-updated) system_update.sh script
|
||||
update_script = os.path.join(os.path.dirname(__file__), 'system_update.sh')
|
||||
log.info("Handing off to system_update.sh...")
|
||||
await asyncio.create_subprocess_exec(
|
||||
"bash", update_script,
|
||||
cwd=project_root,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE
|
||||
)
|
||||
# Don't wait for completion as we'll be restarted
|
||||
await asyncio.sleep(0.5)
|
||||
@@ -1677,5 +1829,170 @@ if __name__ == '__main__':
|
||||
level=os.environ.get('LOG_LEVEL', log.INFO),
|
||||
format='%(module)s.py:%(lineno)d %(levelname)s: %(message)s'
|
||||
)
|
||||
|
||||
# ── GIL switch interval ─────────────────────────────────────────────────
|
||||
# CPython releases the GIL every sys.getswitchinterval() seconds (default
|
||||
# 5 ms). The audio pipeline fires every 10 ms, so a 5 ms granularity
|
||||
# means up to half a frame period can be wasted waiting for the GIL.
|
||||
# Reducing to 1 ms gives the BLE thread much tighter access.
|
||||
import sys
|
||||
sys.setswitchinterval(0.001)
|
||||
log.info("GIL switch interval set to 1 ms")
|
||||
|
||||
# ── BLE / audio event loop ──────────────────────────────────────────────
|
||||
# Bumble (serial_asyncio / HCI) and the audio pipeline run exclusively on
|
||||
# this loop. Uvicorn's HTTP accept/read/write callbacks run on a separate
|
||||
# asyncio loop in the main thread, so they can never stall BLE advertising
|
||||
# or audio encoding.
|
||||
#
|
||||
# Route handlers that touch Bumble objects call _on_ble_loop(), which uses
|
||||
# asyncio.run_coroutine_threadsafe() + asyncio.wrap_future() to submit the
|
||||
# coroutine to _ble_loop and await the result back in uvicorn's loop.
|
||||
# Hot-path read-only endpoints (/status, /audio_level*) access
|
||||
# multicaster state directly – Python's GIL makes attribute reads safe.
|
||||
|
||||
def _pthread_sched_lib():
|
||||
"""Return a ctypes handle with correctly typed pthread scheduling symbols.
|
||||
|
||||
Uses RTLD_DEFAULT (ctypes.CDLL(None)) to resolve symbols from all
|
||||
currently loaded shared libraries. This handles both:
|
||||
- glibc < 2.34: pthread_self/pthread_setschedparam live in libpthread.so.0
|
||||
- glibc >= 2.34: pthreads merged into libc.so.6
|
||||
using find_library("c") would miss libpthread on older glibc and cause
|
||||
a NULL function pointer → SEGV when called.
|
||||
|
||||
Explicit restype/argtypes are mandatory: pthread_t is c_ulong (64-bit
|
||||
on ARM64/x86-64) but ctypes defaults to c_int (32-bit), truncating
|
||||
the thread handle and causing a SEGV inside pthread_setschedparam.
|
||||
"""
|
||||
import ctypes
|
||||
|
||||
SCHED_FIFO = 1
|
||||
SCHED_OTHER = 0
|
||||
|
||||
class SchedParam(ctypes.Structure):
|
||||
_fields_ = [("sched_priority", ctypes.c_int)]
|
||||
|
||||
lib = ctypes.CDLL(None, use_errno=True) # RTLD_DEFAULT
|
||||
|
||||
lib.pthread_self.restype = ctypes.c_ulong
|
||||
lib.pthread_self.argtypes = []
|
||||
|
||||
lib.pthread_getschedparam.restype = ctypes.c_int
|
||||
lib.pthread_getschedparam.argtypes = [
|
||||
ctypes.c_ulong,
|
||||
ctypes.POINTER(ctypes.c_int),
|
||||
ctypes.POINTER(SchedParam),
|
||||
]
|
||||
lib.pthread_setschedparam.restype = ctypes.c_int
|
||||
lib.pthread_setschedparam.argtypes = [
|
||||
ctypes.c_ulong,
|
||||
ctypes.c_int,
|
||||
ctypes.POINTER(SchedParam),
|
||||
]
|
||||
return lib, SchedParam, SCHED_FIFO, SCHED_OTHER
|
||||
|
||||
def _configure_ble_thread_scheduling():
|
||||
"""Confirm or establish SCHED_FIFO for the BLE/audio thread.
|
||||
|
||||
When launched via the systemd unit (CPUSchedulingPolicy=fifo), new
|
||||
threads inherit the process RT policy automatically – just log and
|
||||
return. When run directly (development), attempt to elevate to
|
||||
SCHED_FIFO/30 (requires CAP_SYS_NICE), falling back gracefully.
|
||||
"""
|
||||
import ctypes
|
||||
try:
|
||||
lib, SchedParam, SCHED_FIFO, _ = _pthread_sched_lib()
|
||||
tid = lib.pthread_self()
|
||||
policy = ctypes.c_int(-1)
|
||||
param = SchedParam(0)
|
||||
lib.pthread_getschedparam(tid, ctypes.byref(policy), ctypes.byref(param))
|
||||
|
||||
if policy.value == SCHED_FIFO:
|
||||
log.info("[BLE-LOOP] Already SCHED_FIFO priority=%d (inherited from systemd)",
|
||||
param.sched_priority)
|
||||
return
|
||||
|
||||
param.sched_priority = 30
|
||||
ret = lib.pthread_setschedparam(tid, SCHED_FIFO, ctypes.byref(param))
|
||||
if ret == 0:
|
||||
log.info("[BLE-LOOP] SCHED_FIFO priority=30 set")
|
||||
else:
|
||||
err = ctypes.get_errno()
|
||||
log.warning("[BLE-LOOP] SCHED_FIFO failed (errno=%d: %s) – "
|
||||
"use systemd CPUSchedulingPolicy=fifo or grant CAP_SYS_NICE",
|
||||
err, os.strerror(err))
|
||||
try:
|
||||
os.setpriority(os.PRIO_PROCESS, 0,
|
||||
os.getpriority(os.PRIO_PROCESS, 0) - 5)
|
||||
except PermissionError:
|
||||
pass
|
||||
except Exception as exc:
|
||||
log.warning("[BLE-LOOP] Scheduling setup error: %s", exc)
|
||||
|
||||
def _configure_http_thread_scheduling():
|
||||
"""Demote the HTTP (uvicorn) thread to SCHED_OTHER + nice=+10.
|
||||
|
||||
When systemd sets CPUSchedulingPolicy=fifo, every thread in the
|
||||
process – including uvicorn's main loop – inherits SCHED_FIFO.
|
||||
We demote the HTTP thread back to SCHED_OTHER so the BLE thread
|
||||
always wins CPU arbitration when both are runnable.
|
||||
Lowering scheduling policy never requires special privileges.
|
||||
"""
|
||||
import ctypes
|
||||
try:
|
||||
lib, SchedParam, SCHED_FIFO, SCHED_OTHER = _pthread_sched_lib()
|
||||
tid = lib.pthread_self()
|
||||
policy = ctypes.c_int(-1)
|
||||
param = SchedParam(0)
|
||||
lib.pthread_getschedparam(tid, ctypes.byref(policy), ctypes.byref(param))
|
||||
|
||||
if policy.value == SCHED_FIFO:
|
||||
param.sched_priority = 0
|
||||
ret = lib.pthread_setschedparam(tid, SCHED_OTHER, ctypes.byref(param))
|
||||
if ret == 0:
|
||||
log.info("[HTTP] Demoted SCHED_FIFO → SCHED_OTHER")
|
||||
else:
|
||||
err = ctypes.get_errno()
|
||||
log.warning("[HTTP] Could not demote from SCHED_FIFO (errno=%d)", err)
|
||||
else:
|
||||
log.info("[HTTP] Already SCHED_OTHER, no demotion needed")
|
||||
except Exception as exc:
|
||||
log.warning("[HTTP] Scheduling demotion error: %s", exc)
|
||||
|
||||
try:
|
||||
os.nice(10)
|
||||
log.info("[HTTP] nice=+10 (lower priority)")
|
||||
except Exception as exc:
|
||||
log.debug("[HTTP] os.nice: %s", exc)
|
||||
|
||||
_ble_loop_ready = threading.Event()
|
||||
|
||||
def _run_ble_loop():
|
||||
# Confirm or establish RT scheduling before entering the event loop.
|
||||
_configure_ble_thread_scheduling()
|
||||
|
||||
async def _ble_runner():
|
||||
global _ble_loop
|
||||
_ble_loop = asyncio.get_running_loop()
|
||||
_ble_loop_ready.set()
|
||||
# Keep the loop alive; it is stopped when the process exits because
|
||||
# this is a daemon thread.
|
||||
await asyncio.Event().wait()
|
||||
|
||||
asyncio.run(_ble_runner())
|
||||
|
||||
_ble_thread = threading.Thread(target=_run_ble_loop, name="ble-loop", daemon=True)
|
||||
_ble_thread.start()
|
||||
if not _ble_loop_ready.wait(timeout=5):
|
||||
log.error("BLE event loop failed to start within 5 s – aborting")
|
||||
raise RuntimeError("BLE event loop startup timeout")
|
||||
log.info("BLE event loop started on thread '%s'", _ble_thread.name)
|
||||
|
||||
# ── HTTP / uvicorn event loop (main thread) ─────────────────────────────
|
||||
# Demote the HTTP thread from SCHED_FIFO (if set by systemd) to
|
||||
# SCHED_OTHER + nice=+10 so the BLE thread always preempts it.
|
||||
_configure_http_thread_scheduling()
|
||||
|
||||
# Bind to localhost only for security: prevents network access, only frontend on same machine can connect
|
||||
uvicorn.run(app, host="127.0.0.1", port=5000, access_log=False)
|
||||
@@ -0,0 +1,90 @@
|
||||
#!/bin/bash
|
||||
# system_update.sh - Runs after git checkout in the Python system_update endpoint.
|
||||
# Called with the current working directory = project root.
|
||||
# All output is also written to /tmp/system_update.log for debugging.
|
||||
|
||||
exec > >(tee -a /tmp/system_update.log) 2>&1
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
|
||||
POETRY="$HOME/.local/bin/poetry"
|
||||
OPENOCD_SRC="$HOME/sw_openocd"
|
||||
OPENOCD_REPO="ssh://git@gitea.summitwave.work:222/auracaster/sw_openocd.git"
|
||||
OPENOCD_BRANCH="change-8818"
|
||||
OPENOCD_MARKER="$OPENOCD_SRC/.last_built_commit"
|
||||
OPENOCD_DIR="$PROJECT_ROOT/src/openocd"
|
||||
|
||||
echo "[system_update] Starting post-checkout update. project_root=$PROJECT_ROOT"
|
||||
|
||||
# 1. poetry install
|
||||
echo "[system_update] Running poetry install..."
|
||||
(cd "$PROJECT_ROOT" && "$POETRY" install)
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "[system_update] ERROR: poetry install failed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 2. Clone/update and build sw_openocd if needed
|
||||
if [ ! -d "$OPENOCD_SRC" ]; then
|
||||
echo "[system_update] Installing sw_openocd build dependencies..."
|
||||
sudo apt install -y git build-essential libtool autoconf texinfo \
|
||||
libusb-1.0-0-dev libftdi1-dev libhidapi-dev pkg-config || \
|
||||
echo "[system_update] WARNING: apt install deps had errors, continuing"
|
||||
sudo apt-get install -y pkg-config libjim-dev || \
|
||||
echo "[system_update] WARNING: apt-get install libjim-dev had errors, continuing"
|
||||
|
||||
echo "[system_update] Cloning sw_openocd branch $OPENOCD_BRANCH..."
|
||||
git clone --branch "$OPENOCD_BRANCH" --single-branch "$OPENOCD_REPO" "$OPENOCD_SRC"
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "[system_update] ERROR: git clone sw_openocd failed"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "[system_update] Updating sw_openocd..."
|
||||
git -C "$OPENOCD_SRC" fetch origin "$OPENOCD_BRANCH"
|
||||
git -C "$OPENOCD_SRC" checkout "$OPENOCD_BRANCH"
|
||||
git -C "$OPENOCD_SRC" pull
|
||||
fi
|
||||
|
||||
OPENOCD_COMMIT=$(git -C "$OPENOCD_SRC" rev-parse HEAD)
|
||||
LAST_BUILT=""
|
||||
[ -f "$OPENOCD_MARKER" ] && LAST_BUILT=$(cat "$OPENOCD_MARKER")
|
||||
|
||||
if [ "$OPENOCD_COMMIT" != "$LAST_BUILT" ]; then
|
||||
echo "[system_update] Building sw_openocd (commit $OPENOCD_COMMIT)..."
|
||||
|
||||
(cd "$OPENOCD_SRC" && ./bootstrap)
|
||||
if [ $? -ne 0 ]; then echo "[system_update] ERROR: openocd bootstrap failed"; exit 1; fi
|
||||
|
||||
(cd "$OPENOCD_SRC" && ./configure --enable-bcm2835gpio --enable-sysfsgpio)
|
||||
if [ $? -ne 0 ]; then echo "[system_update] ERROR: openocd configure failed"; exit 1; fi
|
||||
|
||||
(cd "$OPENOCD_SRC" && make)
|
||||
if [ $? -ne 0 ]; then echo "[system_update] ERROR: openocd make failed"; exit 1; fi
|
||||
|
||||
(cd "$OPENOCD_SRC" && sudo make install)
|
||||
if [ $? -ne 0 ]; then echo "[system_update] ERROR: openocd make install failed"; exit 1; fi
|
||||
|
||||
echo "$OPENOCD_COMMIT" > "$OPENOCD_MARKER"
|
||||
echo "[system_update] sw_openocd built and installed (commit $OPENOCD_COMMIT)"
|
||||
else
|
||||
echo "[system_update] sw_openocd up to date (commit $OPENOCD_COMMIT), skipping build"
|
||||
fi
|
||||
|
||||
# 3. Flash firmware to both SWD interfaces
|
||||
FLASH_SCRIPT="$OPENOCD_DIR/flash.sh"
|
||||
HEX_FILE="$OPENOCD_DIR/merged.hex"
|
||||
|
||||
for IFACE in swd0 swd1; do
|
||||
echo "[system_update] Flashing $IFACE..."
|
||||
(cd "$OPENOCD_DIR" && bash "$FLASH_SCRIPT" -i "$IFACE" -f "$HEX_FILE")
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "[system_update] ERROR: flash $IFACE failed"
|
||||
exit 1
|
||||
fi
|
||||
echo "[system_update] Flash $IFACE complete"
|
||||
done
|
||||
|
||||
# 4. Restart services (this will kill this process too)
|
||||
echo "[system_update] Restarting services..."
|
||||
bash "$PROJECT_ROOT/src/service/update_and_run_server_and_frontend.sh"
|
||||
@@ -0,0 +1 @@
|
||||
Placeholder file to have the activation folder available, otherwise dante activation script fails with 'Unable to write'.
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"trialMode": true,
|
||||
"trialMode": false,
|
||||
"$schema": "./dante.json_schema.json",
|
||||
"platform":
|
||||
{
|
||||
@@ -16,7 +16,7 @@
|
||||
48000
|
||||
],
|
||||
"samplesPerPeriod" : 16,
|
||||
"periodsPerBuffer" : 300,
|
||||
"periodsPerBuffer" : 150,
|
||||
"networkLatencyMinMs" : 2,
|
||||
"networkLatencyDefaultMs" : 5,
|
||||
"supportedEncodings" :
|
||||
@@ -24,7 +24,10 @@
|
||||
"PCM16"
|
||||
],
|
||||
"defaultEncoding" : "PCM16",
|
||||
"numDepCores" : 1
|
||||
"numDepCores" :
|
||||
[
|
||||
3
|
||||
]
|
||||
},
|
||||
"network" :
|
||||
{
|
||||
@@ -50,31 +53,32 @@
|
||||
"alsaAsrc":
|
||||
{
|
||||
"enableAlsaAsrc": true,
|
||||
"cpuAffinity": 3,
|
||||
"deviceConfigurations": [
|
||||
{
|
||||
"deviceIdentifier": "hw:0,0",
|
||||
"deviceIdentifier": "hw:6,0,0",
|
||||
"direction": "playback",
|
||||
"bitDepth": 16,
|
||||
"numOpenChannels": 6,
|
||||
"alsaChannelRange": "0-5",
|
||||
"danteChannelRange": "0-5",
|
||||
"bufferSize": 4800,
|
||||
"bufferSize": 960,
|
||||
"samplesPerPeriod": 16
|
||||
}
|
||||
]
|
||||
},
|
||||
"product" :
|
||||
{
|
||||
"manfId" : "Audinate",
|
||||
"manfName" : "Audinate Pty Ltd",
|
||||
"modelId" : "OEMDEP",
|
||||
"modelName" : "Linux Dante Embedded Platform",
|
||||
"manfId" : "SummitFC",
|
||||
"manfName" : "Summitwave FlexCo",
|
||||
"modelId" : "TX",
|
||||
"modelName" : "Summitwave TX",
|
||||
"modelVersion" :
|
||||
{
|
||||
"major" : 9,
|
||||
"minor" : 9,
|
||||
"bugfix" : 99
|
||||
"major" : 1,
|
||||
"minor" : 0,
|
||||
"bugfix" : 0
|
||||
},
|
||||
"devicePrefix" : "DEP"
|
||||
"devicePrefix" : "SW-TX"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,8 +6,8 @@ pcm.ch1 {
|
||||
channels 2
|
||||
rate 48000
|
||||
format S16_LE
|
||||
period_size 120
|
||||
buffer_size 240
|
||||
period_size 240
|
||||
buffer_size 960
|
||||
}
|
||||
bindings.0 0
|
||||
}
|
||||
@@ -21,8 +21,8 @@ pcm.ch2 {
|
||||
channels 2
|
||||
rate 48000
|
||||
format S16_LE
|
||||
period_size 120
|
||||
buffer_size 240
|
||||
period_size 240
|
||||
buffer_size 960
|
||||
}
|
||||
bindings.0 1
|
||||
}
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
sudo cp src/misc/asound.conf /etc/asound.conf
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
sudo cp "$SCRIPT_DIR/asound.conf" /etc/asound.conf
|
||||
@@ -0,0 +1,56 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
INTERFACE="swd0"
|
||||
HEX_FILE=""
|
||||
|
||||
usage() {
|
||||
echo "Usage: $0 -f <hex_file> [-i swd0|swd1]"
|
||||
exit 1
|
||||
}
|
||||
|
||||
while getopts "f:i:h" opt; do
|
||||
case "$opt" in
|
||||
f) HEX_FILE="$OPTARG" ;;
|
||||
i)
|
||||
if [[ "$OPTARG" == "swd0" || "$OPTARG" == "swd1" ]]; then
|
||||
INTERFACE="$OPTARG"
|
||||
else
|
||||
usage
|
||||
fi
|
||||
;;
|
||||
h) usage ;;
|
||||
*) usage ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[[ -n "$HEX_FILE" ]] || usage
|
||||
[[ -f "$HEX_FILE" ]] || { echo "HEX file not found: $HEX_FILE"; exit 1; }
|
||||
|
||||
sudo openocd \
|
||||
-f ./raspberrypi-${INTERFACE}.cfg \
|
||||
-c "init" \
|
||||
-c "reset init" \
|
||||
-c "flash banks" \
|
||||
-c "flash write_image $HEX_FILE" \
|
||||
-c "verify_image $HEX_FILE" \
|
||||
-c "reset run" \
|
||||
-c "shutdown"
|
||||
|
||||
sudo openocd \
|
||||
-f ./raspberrypi-${INTERFACE}.cfg \
|
||||
-c "init" \
|
||||
-c "nrf54l.dap apreg 2 0x000 0x1" \
|
||||
-c "sleep 100" \
|
||||
-c "nrf54l.dap apreg 2 0x000 0x0" \
|
||||
-c "shutdown"
|
||||
|
||||
sudo openocd \
|
||||
-f ./raspberrypi-${INTERFACE}.cfg \
|
||||
-c "init" \
|
||||
-c "nrf54l.dap apreg 2 0x000 0x4" \
|
||||
-c "sleep 100" \
|
||||
-c "nrf54l.dap apreg 2 0x000 0x0" \
|
||||
-c "shutdown"
|
||||
|
||||
echo "Flashing complete."
|
||||
+13111
File diff suppressed because it is too large
Load Diff
@@ -5,4 +5,9 @@ adapter gpio swdio 26
|
||||
#adapter gpio trst 26
|
||||
#reset_config trst_only
|
||||
|
||||
|
||||
source [find target/nordic/nrf54l.cfg]
|
||||
|
||||
flash bank $_CHIPNAME.flash nrf54 0x00000000 0 0 0 $_TARGETNAME
|
||||
|
||||
adapter speed 1000
|
||||
|
||||
@@ -5,4 +5,9 @@ adapter gpio swdio 24
|
||||
#adapter gpio trst 27
|
||||
#reset_config trst_only
|
||||
|
||||
|
||||
source [find target/nordic/nrf54l.cfg]
|
||||
|
||||
flash bank $_CHIPNAME.flash nrf54 0x00000000 0 0 0 $_TARGETNAME
|
||||
|
||||
adapter speed 1000
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
#!/bin/bash
|
||||
# NetworkManager dispatcher script: 10-link-local-mgmt
|
||||
#
|
||||
# Temporarily suppresses IPv4 link-local when a DHCP address is available,
|
||||
# using nmcli device modify (active session only, NOT saved to the profile).
|
||||
# The persistent profile always keeps ipv4.link-local=enabled so that
|
||||
# direct-connect (no DHCP) plug-ins always activate and trigger events.
|
||||
#
|
||||
# Triggers: up, down, dhcp4-change on ethernet interfaces
|
||||
# Install to: /etc/NetworkManager/dispatcher.d/10-link-local-mgmt
|
||||
# Permissions: root:root 0755
|
||||
|
||||
INTERFACE="$1"
|
||||
ACTION="$2"
|
||||
CONNECTION_NAME="${CONNECTION_ID:-}"
|
||||
|
||||
# Only handle ethernet interfaces
|
||||
if [[ ! "$INTERFACE" =~ ^eth ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# If CONNECTION_ID env var is not set, look up the active connection for this interface
|
||||
if [ -z "$CONNECTION_NAME" ]; then
|
||||
CONNECTION_NAME=$(nmcli -t -f NAME,DEVICE connection show --active 2>/dev/null \
|
||||
| grep ":${INTERFACE}$" | cut -d: -f1 | head -n1)
|
||||
[ -z "$CONNECTION_NAME" ] && exit 0
|
||||
fi
|
||||
|
||||
# Update /etc/avahi/hosts to point mDNS hostname at the best available DHCP address
|
||||
# across all ethernet interfaces (so Avahi doesn't advertise a link-local address).
|
||||
update_avahi() {
|
||||
local hostname
|
||||
hostname=$(hostname)
|
||||
# Find first non-link-local IPv4 across all ethernet interfaces
|
||||
local dhcp_ip
|
||||
dhcp_ip=$(ip -4 addr show 2>/dev/null \
|
||||
| grep -A5 ': eth' \
|
||||
| grep -oP '(?<=inet\s)\d+(\.\d+){3}' \
|
||||
| grep -v '^127\.' \
|
||||
| grep -v '^169\.254\.' \
|
||||
| head -n1)
|
||||
|
||||
if [ -n "$dhcp_ip" ]; then
|
||||
mkdir -p /etc/avahi
|
||||
echo "$dhcp_ip $hostname $hostname.local" > /etc/avahi/hosts
|
||||
logger -t nm-link-local "Avahi: pinned $hostname -> $dhcp_ip"
|
||||
else
|
||||
rm -f /etc/avahi/hosts
|
||||
logger -t nm-link-local "Avahi: removed hosts pin, using all addresses"
|
||||
fi
|
||||
systemctl restart avahi-daemon 2>/dev/null
|
||||
}
|
||||
|
||||
case "$ACTION" in
|
||||
up|dhcp4-change)
|
||||
DHCP_IP=$(ip -4 addr show "$INTERFACE" 2>/dev/null \
|
||||
| grep -oP '(?<=inet\s)\d+(\.\d+){3}' \
|
||||
| grep -v '^127\.' \
|
||||
| grep -v '^169\.254\.' \
|
||||
| head -n1)
|
||||
|
||||
if [ -n "$DHCP_IP" ]; then
|
||||
logger -t nm-link-local "[$INTERFACE] DHCP $DHCP_IP detected — suppressing link-local (session only)"
|
||||
# Use device modify (not connection modify) so the persistent profile keeps
|
||||
# ipv4.link-local=enabled. This ensures direct-connect plug-ins always activate.
|
||||
# Run in background after a delay — nmcli blocks on NM, which is waiting for
|
||||
# this dispatcher to return, causing a deadlock if called synchronously.
|
||||
(sleep 2 && nmcli device modify "$INTERFACE" ipv4.link-local disabled 2>/dev/null \
|
||||
&& logger -t nm-link-local "[$INTERFACE] Link-local suppressed for current session") &
|
||||
else
|
||||
logger -t nm-link-local "[$INTERFACE] No DHCP on $INTERFACE — keeping link-local active"
|
||||
fi
|
||||
update_avahi
|
||||
;;
|
||||
|
||||
down)
|
||||
# Profile always has ipv4.link-local=enabled so no action needed here.
|
||||
# The suppression from device modify was session-only and is gone when the
|
||||
# connection goes down.
|
||||
logger -t nm-link-local "[$INTERFACE] Down — link-local will be active on next connect"
|
||||
update_avahi
|
||||
;;
|
||||
esac
|
||||
@@ -10,6 +10,8 @@ WorkingDirectory=/home/caster/bumble-auracast/src/auracast/server
|
||||
ExecStart=/home/caster/bumble-auracast/src/auracast/server/start_frontend_https.sh
|
||||
Restart=on-failure
|
||||
Environment=LOG_LEVEL=INFO
|
||||
AllowedCPUs=0
|
||||
CPUAffinity=0
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
||||
@@ -9,6 +9,8 @@ ExecStart=/home/caster/bumble-auracast/.venv/bin/python src/auracast/multicast_s
|
||||
Restart=on-failure
|
||||
Environment=PYTHONUNBUFFERED=1
|
||||
Environment=LOG_LEVEL=INFO
|
||||
AllowedCPUs=0
|
||||
CPUAffinity=0
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
[Unit]
|
||||
Description=Auracast Backend Server
|
||||
After=network.target
|
||||
After=network.target dep.service
|
||||
Wants=dep.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
@@ -10,8 +11,10 @@ Restart=on-failure
|
||||
Environment=PYTHONUNBUFFERED=1
|
||||
Environment=LOG_LEVEL=INFO
|
||||
CPUSchedulingPolicy=fifo
|
||||
CPUSchedulingPriority=99
|
||||
CPUSchedulingPriority=10
|
||||
LimitRTPRIO=99
|
||||
AllowedCPUs=0,1,2
|
||||
CPUAffinity=0,1,2
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
[Unit]
|
||||
Description=DEP (Dante Embedded Platform) Container
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
RemainAfterExit=yes
|
||||
WorkingDirectory=/home/caster/bumble-auracast/src/dep/dante_package
|
||||
ExecStart=/bin/bash dep.sh start
|
||||
ExecStop=/bin/bash dep.sh stop
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -9,6 +9,8 @@ ExecStartPre=/bin/sh -lc 'for i in $(seq 1 60); do ip route show default >/dev/n
|
||||
ExecStart=/usr/bin/pipewire-aes67 -c /home/caster/bumble-auracast/src/service/aes67/pipewire-aes67.conf
|
||||
Restart=always
|
||||
RestartSec=5s
|
||||
AllowedCPUs=0
|
||||
CPUAffinity=0
|
||||
# Avoid StartLimitHit on quick failures during boot; let RestartSec handle pacing
|
||||
StartLimitIntervalSec=0
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ After=network.target
|
||||
Type=simple
|
||||
ExecStart=/usr/sbin/ptp4l -i eth0 -f /home/caster/bumble-auracast/src/service/aes67/ptp_aes67_1.conf
|
||||
Restart=on-failure
|
||||
AllowedCPUs=0
|
||||
CPUAffinity=0
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
|
||||
|
||||
Regular → Executable
+22
-19
@@ -14,24 +14,22 @@ while IFS=: read -r name type; do
|
||||
fi
|
||||
done < <(nmcli -t -f NAME,TYPE connection show)
|
||||
|
||||
# Configure Avahi to prefer DHCP address over static fallback for mDNS
|
||||
# Get the DHCP-assigned IP (first non-localhost, non-192.168.42.10 IP)
|
||||
DHCP_IP=$(ip -4 addr show eth0 2>/dev/null | grep -oP '(?<=inet\s)\d+(\.\d+){3}' | grep -v '^127\.' | grep -v '^169\.254\.' | head -n1)
|
||||
HOSTNAME=$(hostname)
|
||||
|
||||
if [ -n "$DHCP_IP" ]; then
|
||||
echo "DHCP address detected: $DHCP_IP, configuring Avahi to prefer it for mDNS."
|
||||
# Add entry to /etc/avahi/hosts to explicitly map hostname to DHCP IP
|
||||
sudo mkdir -p /etc/avahi
|
||||
echo "$DHCP_IP $HOSTNAME $HOSTNAME.local" | sudo tee /etc/avahi/hosts > /dev/null
|
||||
# Restart avahi to apply the hosts file
|
||||
sudo systemctl restart avahi-daemon
|
||||
else
|
||||
echo "No DHCP address detected, mDNS will use link local"
|
||||
# Remove hosts file to let Avahi advertise all IPs
|
||||
sudo rm -f /etc/avahi/hosts
|
||||
sudo systemctl restart avahi-daemon
|
||||
fi
|
||||
# Ensure Loopback is loaded with a fixed name and index
|
||||
# Needed for dante
|
||||
# TODO image when we create the next image this should be part of it
|
||||
echo "options snd-aloop index=6 id=Loopback pcm_substreams=6" | sudo tee /etc/modprobe.d/snd-aloop.conf
|
||||
echo snd-aloop | sudo tee /etc/modules-load.d/snd-aloop.conf
|
||||
|
||||
|
||||
|
||||
# Install NetworkManager dispatcher script for link-local / Avahi management
|
||||
sudo cp /home/caster/bumble-auracast/src/service/10-link-local-mgmt /etc/NetworkManager/dispatcher.d/10-link-local-mgmt
|
||||
sudo chown root:root /etc/NetworkManager/dispatcher.d/10-link-local-mgmt
|
||||
sudo chmod 755 /etc/NetworkManager/dispatcher.d/10-link-local-mgmt
|
||||
|
||||
# Copy system service file for DEP
|
||||
sudo cp /home/caster/bumble-auracast/src/service/dep.service /etc/systemd/system/dep.service
|
||||
|
||||
# Copy system service file for frontend
|
||||
sudo cp /home/caster/bumble-auracast/src/service/auracast-frontend.service /etc/systemd/system/auracast-frontend.service
|
||||
@@ -40,20 +38,25 @@ sudo cp /home/caster/bumble-auracast/src/service/auracast-frontend.service /etc/
|
||||
mkdir -p /home/caster/.config/systemd/user
|
||||
cp /home/caster/bumble-auracast/src/service/auracast-server.service /home/caster/.config/systemd/user/auracast-server.service
|
||||
|
||||
# Reload systemd for frontend
|
||||
# Reload systemd for frontend and dep
|
||||
sudo systemctl daemon-reload
|
||||
# Reload user systemd for server
|
||||
systemctl --user daemon-reload
|
||||
|
||||
# Enable DEP to start on boot (system)
|
||||
sudo systemctl enable dep.service
|
||||
# Enable frontend to start on boot (system)
|
||||
sudo systemctl enable auracast-frontend.service
|
||||
# Enable server to start on boot (user)
|
||||
systemctl --user enable auracast-server.service
|
||||
|
||||
# Restart both
|
||||
# Restart all
|
||||
sudo systemctl restart dep.service
|
||||
|
||||
sudo systemctl restart auracast-frontend.service
|
||||
systemctl --user restart auracast-server.service
|
||||
|
||||
#print status
|
||||
sudo systemctl status dep.service --no-pager
|
||||
sudo systemctl status auracast-frontend.service --no-pager
|
||||
systemctl --user status auracast-server.service --no-pager
|
||||
|
||||
Reference in New Issue
Block a user