Compare commits
18 Commits
0.4.1
...
wip_alsaau
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
291d75b137 | ||
|
|
a126613739 | ||
| 036b5f80dd | |||
|
|
e818765b4f | ||
|
|
3d59a6dabf | ||
|
|
cf69ad2957 | ||
|
|
cdfecaf5eb | ||
|
|
1687a2b790 | ||
|
|
a605195646 | ||
|
|
e1d717ed5c | ||
|
|
540d8503ac | ||
|
|
c82f375539 | ||
| 70bde5295f | |||
| f5f93b4b8e | |||
| 3322b9edf4 | |||
| d6230e7522 | |||
| f2382470d8 | |||
| 7c2f0bf0cb |
@@ -4,8 +4,11 @@
|
||||
- this projects uses poetry for package management
|
||||
- if something should be run in a python env use 'poetry run'
|
||||
|
||||
# Environment
|
||||
- this application normally runs on an embedded linux on a cm4
|
||||
|
||||
## Application
|
||||
- this is a bluetooth Auracast transmitter application
|
||||
- if you add a new parameter for a stream make sure it is saved to the settings.json so it is persisted
|
||||
- it consists of multicast_frontend.py and multicast_server.py mainly which connect to each other via a rest api
|
||||
|
||||
- after you implemented something the user will mainly test it and you should call the update_and_run_server_and_frontend.sh script if the server and frontend were already running.
|
||||
|
||||
63
poetry.lock
generated
63
poetry.lock
generated
@@ -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"
|
||||
@@ -2976,4 +2947,4 @@ test = ["pytest", "pytest-asyncio"]
|
||||
[metadata]
|
||||
lock-version = "2.1"
|
||||
python-versions = ">=3.11"
|
||||
content-hash = "3c9f92c7a5af40f98da9c7824d9c2a6f7eb809e91e43cfef4995761b2e887256"
|
||||
content-hash = "7c3c5cf6a836a9d7705e3b120610d98912cfd228b9abe162e15e6bed5bcb44a1"
|
||||
|
||||
@@ -17,7 +17,8 @@ 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)",
|
||||
"pyalsaaudio @ git+ssh://git@gitea.summitwave.work:222/auracaster/sw_pyalsaaudio.git@b3d11582e03df6929b2e7acbaa1306afc7b8a6bc"
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
@@ -55,6 +55,7 @@ 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
|
||||
|
||||
@@ -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 = 40
|
||||
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}',
|
||||
@@ -638,6 +689,12 @@ class Streamer():
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def get_audio_levels(self) -> list[float]:
|
||||
"""Return current RMS audio levels (0.0-1.0) for each BIG."""
|
||||
if not self.bigs:
|
||||
return []
|
||||
return [big.get('_audio_level_rms', 0.0) for big in self.bigs.values()]
|
||||
|
||||
async def stream(self):
|
||||
|
||||
bigs = self.bigs
|
||||
@@ -805,6 +862,13 @@ 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
|
||||
@@ -852,6 +916,19 @@ 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
|
||||
big['_audio_level_rms'] = float(rms)
|
||||
|
||||
# Measure LC3 encoding time
|
||||
t1 = time.perf_counter()
|
||||
num_bis = big.get('num_bis', 1)
|
||||
|
||||
@@ -37,6 +37,12 @@ class Multicaster:
|
||||
'is_initialized': self.is_auracast_init,
|
||||
'is_streaming': streaming,
|
||||
}
|
||||
|
||||
def get_audio_levels(self) -> list[float]:
|
||||
"""Return current RMS audio levels (0.0-1.0) for each BIG."""
|
||||
if self.streamer is not None and self.streamer.is_streaming:
|
||||
return self.streamer.get_audio_levels()
|
||||
return []
|
||||
|
||||
async def init_broadcast(self):
|
||||
self.device_acm = multicast.create_device(self.global_conf)
|
||||
@@ -137,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(
|
||||
|
||||
42
src/auracast/server/http_to_https_redirect.py
Normal file
42
src/auracast/server/http_to_https_redirect.py
Normal file
@@ -0,0 +1,42 @@
|
||||
"""Minimal HTTP server that redirects all requests to HTTPS (port 443).
|
||||
|
||||
Run on port 80 alongside the HTTPS Streamlit frontend so that users who
|
||||
type a bare IP address into their browser are automatically forwarded.
|
||||
"""
|
||||
|
||||
import http.server
|
||||
import sys
|
||||
|
||||
|
||||
class RedirectHandler(http.server.BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
host = self.headers.get("Host", "").split(":")[0] or self.server.server_address[0]
|
||||
target = f"https://{host}{self.path}"
|
||||
self.send_response(301)
|
||||
self.send_header("Location", target)
|
||||
self.end_headers()
|
||||
|
||||
# Handle every method the same way
|
||||
do_POST = do_GET
|
||||
do_PUT = do_GET
|
||||
do_DELETE = do_GET
|
||||
do_HEAD = do_GET
|
||||
|
||||
def log_message(self, format, *args):
|
||||
# Keep logging minimal
|
||||
sys.stderr.write(f"[http-redirect] {self.address_string()} -> https {args[0] if args else ''}\n")
|
||||
|
||||
|
||||
def main():
|
||||
port = int(sys.argv[1]) if len(sys.argv) > 1 else 80
|
||||
server = http.server.HTTPServer(("0.0.0.0", port), RedirectHandler)
|
||||
print(f"HTTP->HTTPS redirect server listening on 0.0.0.0:{port}")
|
||||
try:
|
||||
server.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
server.server_close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,6 +1,7 @@
|
||||
# frontend/app.py
|
||||
import os
|
||||
import time
|
||||
import math
|
||||
import logging as log
|
||||
from PIL import Image
|
||||
|
||||
@@ -197,18 +198,85 @@ 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
|
||||
analog_gain_value = 50 # default (ALSA 10-60 range)
|
||||
software_boost_db = 0 # default
|
||||
if audio_mode == "Analog":
|
||||
saved_analog_gain = saved_settings.get('analog_gain', 50)
|
||||
analog_gain_value = st.slider(
|
||||
# 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=10,
|
||||
max_value=60,
|
||||
value=min(saved_analog_gain, 60),
|
||||
min_value=0,
|
||||
max_value=100,
|
||||
value=saved_display,
|
||||
step=5,
|
||||
disabled=is_streaming,
|
||||
help="ADC gain level for both analog inputs (10-60%). Default is 50%."
|
||||
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."
|
||||
)
|
||||
|
||||
# Audio level monitor (checkbox, not persisted across reloads)
|
||||
show_level_monitor = st.checkbox("Audio level monitor", value=False, disabled=not is_streaming,
|
||||
help="Show real-time audio level meters for active radios. Only works while streaming.")
|
||||
|
||||
if show_level_monitor and is_streaming:
|
||||
@st.fragment(run_every=0.2)
|
||||
def _audio_level_fragment():
|
||||
cols = st.columns(2)
|
||||
# Radio 1
|
||||
with cols[0]:
|
||||
try:
|
||||
r = requests.get(f"{BACKEND_URL}/audio_level", timeout=0.2)
|
||||
levels = r.json().get("levels", []) if r.ok else []
|
||||
except Exception:
|
||||
levels = []
|
||||
if levels:
|
||||
rms = max(levels)
|
||||
db = max(-60.0, 20.0 * (math.log10(rms) if rms > 0 else -3.0))
|
||||
pct = int(max(0, min(100, (db + 60) * 100 / 60)))
|
||||
st.markdown(
|
||||
f"**Radio 1**"
|
||||
f'<div style="background:#333;border-radius:4px;height:18px;width:100%;margin-top:4px;">'
|
||||
f'<div style="background:#2ecc71;height:100%;width:{pct}%;border-radius:4px;transition:width 0.15s;"></div>'
|
||||
f'</div>',
|
||||
unsafe_allow_html=True,
|
||||
)
|
||||
else:
|
||||
st.markdown("**Radio 1** --")
|
||||
# Radio 2
|
||||
with cols[1]:
|
||||
try:
|
||||
r2 = requests.get(f"{BACKEND_URL}/audio_level2", timeout=0.2)
|
||||
levels2 = r2.json().get("levels", []) if r2.ok else []
|
||||
except Exception:
|
||||
levels2 = []
|
||||
if levels2:
|
||||
rms2 = max(levels2)
|
||||
db2 = max(-60.0, 20.0 * (math.log10(rms2) if rms2 > 0 else -3.0))
|
||||
pct2 = int(max(0, min(100, (db2 + 60) * 100 / 60)))
|
||||
st.markdown(
|
||||
f"**Radio 2**"
|
||||
f'<div style="background:#333;border-radius:4px;height:18px;width:100%;margin-top:4px;">'
|
||||
f'<div style="background:#2ecc71;height:100%;width:{pct2}%;border-radius:4px;transition:width 0.15s;"></div>'
|
||||
f'</div>',
|
||||
unsafe_allow_html=True,
|
||||
)
|
||||
else:
|
||||
st.markdown("**Radio 2** --")
|
||||
_audio_level_fragment()
|
||||
|
||||
# Placeholder for validation errors (will be filled in later)
|
||||
validation_error_placeholder = st.empty()
|
||||
@@ -617,6 +685,7 @@ else:
|
||||
'presentation_delay_ms': presentation_delay_ms2,
|
||||
'qos_preset': qos_preset2,
|
||||
'analog_gain': analog_gain_value,
|
||||
'software_boost_db': software_boost_db,
|
||||
}
|
||||
|
||||
radio1_cfg = {
|
||||
@@ -633,6 +702,7 @@ else:
|
||||
'qos_preset': qos_preset1,
|
||||
'stereo_mode': stereo_enabled,
|
||||
'analog_gain': analog_gain_value,
|
||||
'software_boost_db': software_boost_db,
|
||||
}
|
||||
|
||||
if audio_mode == "Network - Dante":
|
||||
@@ -1529,6 +1599,7 @@ 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)),
|
||||
)
|
||||
],
|
||||
)
|
||||
@@ -1699,6 +1770,123 @@ with st.expander("System control", expanded=False):
|
||||
except Exception as e:
|
||||
st.warning(f"Could not read temperatures: {e}")
|
||||
|
||||
st.subheader("Network Information")
|
||||
try:
|
||||
import subprocess, socket
|
||||
device_hostname = socket.gethostname()
|
||||
st.write(f"Hostname: **{device_hostname}**")
|
||||
|
||||
network_info_resp = requests.get(f"{BACKEND_URL}/network_info", timeout=5)
|
||||
if network_info_resp.status_code == 200:
|
||||
network_data = network_info_resp.json()
|
||||
interfaces = network_data.get("interfaces", {})
|
||||
port_mapping = network_data.get("port_mapping", {})
|
||||
|
||||
for port_name in ["port1", "port2"]:
|
||||
if port_name not in port_mapping:
|
||||
continue
|
||||
|
||||
interface_name = port_mapping[port_name]
|
||||
interface_data = interfaces.get(interface_name, {})
|
||||
|
||||
port_label = "Port 1" if port_name == "port1" else "Port 2"
|
||||
st.markdown(f"### {port_label}")
|
||||
|
||||
ip_address = interface_data.get("ip_address", "N/A")
|
||||
is_dhcp = interface_data.get("is_dhcp", True)
|
||||
|
||||
st.write(f"Interface: **{interface_name}**")
|
||||
st.write(f"IP Address: **{ip_address}**")
|
||||
|
||||
col1, col2 = st.columns([1, 3])
|
||||
with col1:
|
||||
toggle_key = f"{port_name}_dhcp_toggle"
|
||||
current_mode = "DHCP" if is_dhcp else "Static IP"
|
||||
new_mode = st.radio(
|
||||
"Mode",
|
||||
options=["DHCP", "Static IP"],
|
||||
index=0 if is_dhcp else 1,
|
||||
key=toggle_key,
|
||||
horizontal=True
|
||||
)
|
||||
|
||||
with col2:
|
||||
if new_mode == "Static IP":
|
||||
ip_input_key = f"{port_name}_ip_input"
|
||||
default_ip = ip_address if ip_address != "N/A" and not is_dhcp else ""
|
||||
new_ip = st.text_input(
|
||||
"Static IP Address",
|
||||
value=default_ip,
|
||||
key=ip_input_key,
|
||||
placeholder="192.168.1.100"
|
||||
)
|
||||
|
||||
if st.button(f"Apply", key=f"{port_name}_apply_btn"):
|
||||
if not new_ip:
|
||||
st.error("Please enter an IP address")
|
||||
else:
|
||||
import re
|
||||
ip_pattern = re.compile(r'^(\d{1,3}\.){3}\d{1,3}$')
|
||||
if not ip_pattern.match(new_ip):
|
||||
st.error("Invalid IP address format")
|
||||
else:
|
||||
octets = new_ip.split('.')
|
||||
if not all(0 <= int(octet) <= 255 for octet in octets):
|
||||
st.error("IP address octets must be between 0 and 255")
|
||||
else:
|
||||
try:
|
||||
config_payload = {
|
||||
"interface": interface_name,
|
||||
"is_dhcp": False,
|
||||
"ip_address": new_ip,
|
||||
"netmask": "24"
|
||||
}
|
||||
config_resp = requests.post(
|
||||
f"{BACKEND_URL}/set_network_config",
|
||||
json=config_payload,
|
||||
timeout=10
|
||||
)
|
||||
if config_resp.status_code == 200:
|
||||
st.success(f"Static IP {new_ip} applied to {interface_name}")
|
||||
time.sleep(2)
|
||||
st.rerun()
|
||||
else:
|
||||
st.error(f"Failed to apply configuration: {config_resp.text}")
|
||||
except Exception as e:
|
||||
st.error(f"Error applying configuration: {e}")
|
||||
else:
|
||||
if new_mode != current_mode:
|
||||
if st.button(f"Apply DHCP", key=f"{port_name}_dhcp_apply_btn"):
|
||||
try:
|
||||
config_payload = {
|
||||
"interface": interface_name,
|
||||
"is_dhcp": True
|
||||
}
|
||||
config_resp = requests.post(
|
||||
f"{BACKEND_URL}/set_network_config",
|
||||
json=config_payload,
|
||||
timeout=10
|
||||
)
|
||||
if config_resp.status_code == 200:
|
||||
st.success(f"DHCP enabled for {interface_name}")
|
||||
time.sleep(2)
|
||||
st.rerun()
|
||||
else:
|
||||
st.error(f"Failed to apply configuration: {config_resp.text}")
|
||||
except Exception as e:
|
||||
st.error(f"Error applying configuration: {e}")
|
||||
|
||||
st.markdown("---")
|
||||
else:
|
||||
result = subprocess.run(["hostname", "-I"], capture_output=True, text=True, timeout=2)
|
||||
ips = [ip for ip in result.stdout.strip().split() if not ip.startswith('127.') and ':' not in ip]
|
||||
if ips:
|
||||
st.write(f"IP Address: **{ips[0]}**")
|
||||
else:
|
||||
st.warning("No valid IP address found.")
|
||||
except Exception as e:
|
||||
st.warning(f"Could not determine network info: {e}")
|
||||
|
||||
st.subheader("CA Certificate")
|
||||
st.caption("Download the CA certificate to trust this device's HTTPS connection.")
|
||||
try:
|
||||
|
||||
@@ -26,6 +26,10 @@ from auracast.utils.sounddevice_utils import (
|
||||
)
|
||||
|
||||
load_dotenv()
|
||||
|
||||
# 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')
|
||||
@@ -387,18 +391,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
|
||||
|
||||
@@ -493,6 +494,7 @@ async def init_radio(transport: str, conf: auracast_config.AuracastConfigGroup,
|
||||
'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,
|
||||
'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],
|
||||
@@ -593,6 +595,20 @@ async def get_status():
|
||||
|
||||
return status
|
||||
|
||||
@app.get("/audio_level")
|
||||
async def get_audio_level():
|
||||
"""Return current RMS audio levels for primary radio (lightweight, for polling)."""
|
||||
if multicaster1 is None:
|
||||
return {"levels": []}
|
||||
return {"levels": multicaster1.get_audio_levels()}
|
||||
|
||||
@app.get("/audio_level2")
|
||||
async def get_audio_level2():
|
||||
"""Return current RMS audio levels for secondary radio (lightweight, for polling)."""
|
||||
if multicaster2 is None:
|
||||
return {"levels": []}
|
||||
return {"levels": multicaster2.get_audio_levels()}
|
||||
|
||||
async def _autostart_from_settings():
|
||||
settings1 = load_stream_settings() or {}
|
||||
settings2 = load_stream_settings2() or {}
|
||||
@@ -736,6 +752,7 @@ 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(
|
||||
@@ -890,6 +907,7 @@ 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(
|
||||
@@ -923,7 +941,7 @@ async def _startup_autostart_event():
|
||||
log.info("[STARTUP] Auracast multicast server startup: initializing settings cache, I2C, and PipeWire cache")
|
||||
|
||||
# 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)
|
||||
@@ -1446,6 +1464,212 @@ async def delete_recordings():
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@app.get("/network_info")
|
||||
async def get_network_info():
|
||||
"""Get network information for all ethernet interfaces."""
|
||||
try:
|
||||
interfaces = {}
|
||||
|
||||
hardcoded_devices = ["eth0", "eth1"]
|
||||
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"nmcli", "-t", "-f", "NAME,DEVICE", "connection", "show",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE
|
||||
)
|
||||
stdout, stderr = await proc.communicate()
|
||||
|
||||
device_to_connection = {}
|
||||
if proc.returncode == 0:
|
||||
for line in stdout.decode().strip().split('\n'):
|
||||
if not line:
|
||||
continue
|
||||
parts = line.split(':')
|
||||
if len(parts) >= 2:
|
||||
connection_name = parts[0]
|
||||
device_name = parts[1]
|
||||
if device_name in hardcoded_devices:
|
||||
device_to_connection[device_name] = connection_name
|
||||
|
||||
for device in hardcoded_devices:
|
||||
ip_address = None
|
||||
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"nmcli", "-t", "-f", "IP4.ADDRESS", "device", "show", device,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE
|
||||
)
|
||||
stdout, stderr = await proc.communicate()
|
||||
|
||||
if proc.returncode == 0:
|
||||
for line in stdout.decode().strip().split('\n'):
|
||||
if line.startswith('IP4.ADDRESS'):
|
||||
ip_parts = line.split(':')
|
||||
if len(ip_parts) >= 2:
|
||||
full_ip = ip_parts[1]
|
||||
ip_address = full_ip.split('/')[0]
|
||||
if not ip_address.startswith('169.254.'):
|
||||
break
|
||||
|
||||
method = "auto"
|
||||
connection_name = device_to_connection.get(device)
|
||||
if connection_name:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"nmcli", "-t", "-f", "ipv4.method", "connection", "show", connection_name,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE
|
||||
)
|
||||
stdout, stderr = await proc.communicate()
|
||||
|
||||
if proc.returncode == 0:
|
||||
for line in stdout.decode().strip().split('\n'):
|
||||
if line.startswith('ipv4.method:'):
|
||||
method = line.split(':')[1]
|
||||
break
|
||||
|
||||
is_dhcp = method == "auto"
|
||||
|
||||
interfaces[device] = {
|
||||
"ip_address": ip_address or "N/A",
|
||||
"is_dhcp": is_dhcp,
|
||||
"method": method,
|
||||
"connection_name": connection_name
|
||||
}
|
||||
|
||||
port_mapping = {
|
||||
"port1": "eth0",
|
||||
"port2": "eth1"
|
||||
}
|
||||
|
||||
return {
|
||||
"interfaces": interfaces,
|
||||
"port_mapping": port_mapping
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
log.error("Exception in /network_info: %s", e, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@app.post("/set_network_config")
|
||||
async def set_network_config(config: dict):
|
||||
"""Set network configuration (DHCP or Static IP) for a specific interface.
|
||||
|
||||
Expected payload:
|
||||
{
|
||||
"interface": "eth0",
|
||||
"is_dhcp": true/false,
|
||||
"ip_address": "192.168.1.100" (required if is_dhcp is false),
|
||||
"netmask": "24" (optional, defaults to 24)
|
||||
}
|
||||
"""
|
||||
try:
|
||||
interface = config.get("interface")
|
||||
is_dhcp = config.get("is_dhcp", True)
|
||||
ip_address = config.get("ip_address")
|
||||
netmask = config.get("netmask", "24")
|
||||
|
||||
if not interface:
|
||||
raise HTTPException(status_code=400, detail="Interface name is required")
|
||||
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"nmcli", "-t", "-f", "NAME,DEVICE", "connection", "show",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE
|
||||
)
|
||||
stdout, stderr = await proc.communicate()
|
||||
|
||||
if proc.returncode != 0:
|
||||
raise HTTPException(status_code=500, detail="Failed to get network connections")
|
||||
|
||||
connection_name = None
|
||||
for line in stdout.decode().strip().split('\n'):
|
||||
if not line:
|
||||
continue
|
||||
parts = line.split(':')
|
||||
if len(parts) >= 2 and parts[1] == interface:
|
||||
connection_name = parts[0]
|
||||
break
|
||||
|
||||
if not connection_name:
|
||||
log.info(f"No connection found for {interface}, creating new connection")
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"sudo", "nmcli", "con", "add", "type", "ethernet",
|
||||
"ifname", interface, "con-name", f"Wired connection {interface}",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE
|
||||
)
|
||||
stdout, stderr = await proc.communicate()
|
||||
|
||||
if proc.returncode != 0:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to create connection for {interface}: {stderr.decode()}")
|
||||
|
||||
connection_name = f"Wired connection {interface}"
|
||||
|
||||
if is_dhcp:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"sudo", "nmcli", "con", "modify", connection_name, "ipv4.method", "auto",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE
|
||||
)
|
||||
await proc.communicate()
|
||||
|
||||
if proc.returncode != 0:
|
||||
raise HTTPException(status_code=500, detail="Failed to set DHCP mode")
|
||||
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"sudo", "nmcli", "con", "modify", connection_name, "ipv4.addresses", "",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE
|
||||
)
|
||||
await proc.communicate()
|
||||
|
||||
else:
|
||||
if not ip_address:
|
||||
raise HTTPException(status_code=400, detail="IP address is required for static configuration")
|
||||
|
||||
import re
|
||||
ip_pattern = re.compile(r'^(\d{1,3}\.){3}\d{1,3}$')
|
||||
if not ip_pattern.match(ip_address):
|
||||
raise HTTPException(status_code=400, detail="Invalid IP address format")
|
||||
|
||||
octets = ip_address.split('.')
|
||||
if not all(0 <= int(octet) <= 255 for octet in octets):
|
||||
raise HTTPException(status_code=400, detail="IP address octets must be between 0 and 255")
|
||||
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"sudo", "nmcli", "con", "modify", connection_name,
|
||||
"ipv4.method", "manual",
|
||||
"ipv4.addresses", f"{ip_address}/{netmask}",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE
|
||||
)
|
||||
await proc.communicate()
|
||||
|
||||
if proc.returncode != 0:
|
||||
raise HTTPException(status_code=500, detail="Failed to set static IP")
|
||||
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"sudo", "nmcli", "con", "up", connection_name,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE
|
||||
)
|
||||
stdout, stderr = await proc.communicate()
|
||||
|
||||
if proc.returncode != 0:
|
||||
log.info("Connection activation returned non-zero (may be expected if no cable): %s", stderr.decode())
|
||||
|
||||
return {"status": "success", "message": f"Network configuration updated for {interface}"}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
log.error("Exception in /set_network_config: %s", e, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
import os
|
||||
os.chdir(os.path.dirname(__file__))
|
||||
|
||||
@@ -33,5 +33,12 @@ echo "Using Avahi domain: $AVAHI_DOMAIN"
|
||||
# Path to poetry binary
|
||||
POETRY_BIN="/home/caster/.local/bin/poetry"
|
||||
|
||||
# Start HTTP->HTTPS redirect server on port 80 (background)
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
python3 "$SCRIPT_DIR/http_to_https_redirect.py" 80 &
|
||||
REDIRECT_PID=$!
|
||||
echo "HTTP->HTTPS redirect server started (PID $REDIRECT_PID)"
|
||||
trap "kill $REDIRECT_PID 2>/dev/null" EXIT
|
||||
|
||||
# Start Streamlit HTTPS server (port 443)
|
||||
$POETRY_BIN run streamlit run multicast_frontend.py --server.port 443 --server.address 0.0.0.0 --server.enableCORS false --server.enableXsrfProtection false --server.headless true --server.sslCertFile "$CERT" --server.sslKeyFile "$KEY" --browser.gatherUsageStats false
|
||||
|
||||
@@ -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
|
||||
29
src/service/update_and_run_server_and_frontend.sh
Normal file → Executable file
29
src/service/update_and_run_server_and_frontend.sh
Normal file → Executable file
@@ -4,6 +4,35 @@ set -e
|
||||
# This script installs, enables, and restarts the auracast-server and auracast-frontend services
|
||||
# Requires sudo privileges
|
||||
|
||||
# Ensure static link local is activated (for direct laptop connection)
|
||||
# Enable link-local for all wired ethernet connections
|
||||
while IFS=: read -r name type; do
|
||||
if [[ "$type" == *"ethernet"* ]]; then
|
||||
echo "Enabling IPv4 link-local for connection: $name"
|
||||
sudo nmcli connection modify "$name" ipv4.link-local enabled 2>/dev/null || echo "Failed to modify $name"
|
||||
sudo nmcli connection up "$name" 2>/dev/null || echo "Failed to bring up $name"
|
||||
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
|
||||
|
||||
# Copy system service file for frontend
|
||||
sudo cp /home/caster/bumble-auracast/src/service/auracast-frontend.service /etc/systemd/system/auracast-frontend.service
|
||||
|
||||
|
||||
Reference in New Issue
Block a user