feature/network_audio #6

Merged
pstruebi merged 49 commits from feature/network_audio into main 2025-08-27 10:44:02 +00:00
69 changed files with 3827 additions and 189 deletions

4
.gitignore vendored
View File

@@ -38,3 +38,7 @@ __pycache__/
*/.env
wg_config/wg_confs/
records/
src/auracast/server/stream_settings.json
src/auracast/server/certs/per_device/
src/auracast/.env

View File

@@ -1,9 +1,9 @@
# TODO: investigate using -alpine in the future
FROM python:3.11
FROM python:3.11-bookworm
# Install system dependencies and poetry
RUN apt-get update && apt-get install -y \
iputils-ping \
iputils-ping portaudio19-dev\
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*

194
README.md Normal file
View File

@@ -0,0 +1,194 @@
## Local HTTP/HTTPS Setup with Custom CA
This project provides a dual-port Streamlit server setup for local networks:
- **HTTP** available on port **8502**
- **HTTPS** (trusted with custom CA) available on port **8503**
### How it works
- A custom Certificate Authority (CA) is generated for your organization.
- Each device/server is issued a certificate signed by this CA.
- Customers can import the CA certificate into their OS/browser trust store, so the device's HTTPS connection is fully trusted (no browser warnings).
### Usage
1. **Generate Certificates**
- Run `generate_ca_cert.sh` in `src/auracast/server/`.
- This creates:
- `certs/ca/ca_cert.pem` / `ca_key.pem` (CA cert/key)
- **Distribute `ca_cert.pem` or `ca_cert.crt` to customers** for installation in their trust store.
- This is a one-time operation for your organization.
2. **Start the Server**
- Run `run_http_and_https.sh` in `src/auracast/server/`.
- This starts:
- HTTP Streamlit on port 8500
- HTTPS Streamlit on port 8501 (using the signed device cert)
3. **Client Trust Setup**
- Customers should install `ca_cert.pem` in their operating system or browser trust store to trust the HTTPS connection.
- After this, browsers will show a secure HTTPS connection to the device (no warnings).
### Why this setup?
- **WebRTC and other browser features require HTTPS for local devices.**
- Using a local CA allows trusted HTTPS without needing a public certificate or exposing devices to the internet.
- HTTP is also available for compatibility/testing.
### Advertise Hostname with mDNS
```bash
cd src/auracast/server
sudo ./provision_domain_hostname.sh <new_hostname> <new_domain>
```
- Example:
```bash
sudo ./provision_domain_hostname.sh box1 auracast.local
```
- The script will:
- Validate your input (no dots in hostname)
- Set the system hostname
- Update `/etc/hosts`
- Set the Avahi domain in `/etc/avahi/avahi-daemon.conf`
- Restart Avahi
- Generate a unique per-device certificate and key signed by your CA, stored in `certs/per_device/<hostname>.<domain>/`.
- The certificate will have a SAN matching the device's mDNS name (e.g., `box1-summitwave.local`).
---
### Troubleshooting & Tips
- **Use .local domain** (e.g., `box1-summitwave.local`) - most clients will not resolve multi-label domains.
- **Hostnames must not contain dots** (`.`). Only use single-label names for the system hostname.
- **Avahi domain** can be multi-label (e.g., `auracast.local`).
- **Clients may need** `libnss-mdns` installed and `/etc/nsswitch.conf` configured with `mdns4_minimal` and `mdns4` for multi-label mDNS names.
- If you have issues with mDNS name resolution, check for conflicting mDNS stacks (e.g., systemd-resolved, Bonjour, or other daemons).
- Some Linux clients may not resolve multi-label mDNS names via NSS—test with `avahi-resolve-host-name` and try from another device if needed.
---
After completing these steps, your device will be discoverable as `<hostname>.<domain>` (e.g., `box1.auracast.local`) on the local network via mDNS.
---
## Checking Advertised mDNS Services
Once your device is configured, you can verify that its mDNS advertisement is visible on the network:
- **List all mDNS services:**
```bash
avahi-browse -a
```
Look for your hostname and service (e.g., `box1.auracast.local`).
- **Check specific hostname resolution:**
```bash
avahi-resolve-host-name box1.auracast.local
avahi-resolve-host-name -4 box1.auracast.local # IPv4 only
avahi-resolve-host-name -6 box1.auracast.local # IPv6 only
```
## Run the application with local webui
- for microphone streaming via the browser, https is required
- poetry run multicast_server.py
- sudo -E PATH="$PATH" bash ./start_frontend_https.sh
- bash start_mdns.sh
## Managing Auracast systemd Services
You can run the backend and frontend as systemd services for easier management and automatic startup on boot.
### 1. Install the service files
Copy the provided service files to your systemd directory (requires sudo):
```bash
sudo cp auracast-server.service /etc/systemd/system/
sudo cp auracast-frontend.service /etc/systemd/system/
```
### 2. Reload systemd
```bash
sudo systemctl daemon-reload
```
### 3. Enable services to start at boot
```bash
sudo systemctl enable auracast-server
sudo systemctl enable auracast-frontend
```
### 4. Start the services
```bash
sudo systemctl start auracast-server
sudo systemctl start auracast-frontend
```
### 5. Stop the services
```bash
sudo systemctl stop auracast-server
sudo systemctl stop auracast-frontend
```
### 6. Disable services to start at boot
```bash
sudo systemctl disable auracast-server
sudo systemctl disable auracast-frontend
```
### 7. Check service status
```bash
sudo systemctl status auracast-server
sudo systemctl status auracast-frontend
```
If you want to run the services as a specific user, edit the `User=` line in the service files accordingly.
# Setup the audio system
sudo apt update
sudo apt remove -y libportaudio2 portaudio19-dev libportaudiocpp0
echo "y" | rpi-update stable
sudo apt install -y pipewire wireplumber pipewire-audio-client-libraries rtkit cpufrequtils
cp src/service/pipewire/99-lowlatency.conf ~/.config/pipewire/pipewire.conf.d/
sudo cpufreq-set -g performance
/etc/modprobe.d/usb-audio-lowlatency.conf
option snd_usb_audio nrpacks=1
sudo apt install -y --no-install-recommends \
git build-essential cmake pkg-config \
libasound2-dev libpulse-devpipewire ethtool linuxptp
git clone https://github.com/PortAudio/portaudio.git
cd portaudio
git checkout 9abe5fe7db729280080a0bbc1397a528cd3ce658
rm -rf build
cmake -S . -B build -G"Unix Makefiles" \
-DBUILD_SHARED_LIBS=ON \
-DPA_USE_ALSA=OFF \
-DPA_USE_PULSEAUDIO=ON \
-DPA_USE_JACK=OFF
cmake --build build -j$(nproc)
sudo cmake --install build # installs to /usr/local/lib
sudo ldconfig # refresh linker cache
# Device commisioning
- generate id_ed25519 keypair
- setup hostname
- sudo bash src/auracast/server/provision_domain_hostname.sh box7-summitwave local
- activate aes67 service
- install udev rule for ptp4l
- sudo cp src/service/aes67/90-pipewire-aes67-ptp.rules /etc/udev/rules.d/
- sudo udevadm control --log-priority=debug --reload-rules
- sudo udevadm trigger
- bash src/service/update_and_run_aes67.sh
- poetry config virtualenvs.in-project true
- poetry install
- activate server and frontend
- bash srv/service/update_and_run_server_and_frontend.sh
- update to latest stable kernel
- echo "y" | rpi-update stable
- place cert
- disable pw login
- reboot
# Known issues:
- When running on a laptop there might be issues switching between usb and browser audio input since they use the same audio device

View File

@@ -1,6 +1,6 @@
services:
multicaster:
container_name: multicaster-test
container_name: multicaster
privileged: true # Grants full access to all devices (for serial access)
restart: unless-stopped
ports:

24
docker-compose-webui.yaml Normal file
View File

@@ -0,0 +1,24 @@
services:
multicaster:
container_name: multicast-webapp
privileged: true # Grants full access to all devices (for serial access)
restart: unless-stopped
ports:
- "8501:8501"
build:
dockerfile: Dockerfile
ssh:
- default=~/.ssh/id_ed25519 #lappi
#- default=~/.ssh/id_rsa #raspi
volumes:
- "/dev/serial:/dev/serial"
- "/dev/snd:/dev/snd"
#devices:
# - /dev/serial/by-id/usb-ZEPHYR_Zephyr_HCI_UART_sample_81BD14B8D71B5662-if00
environment:
LOG_LEVEL: INFO
# start the server and the frontend
command: >
bash -c "python ./auracast/server/multicast_server.py & streamlit run ./auracast/server/multicast_frontend.py"

1323
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -6,13 +6,16 @@ requires-python = ">=3.11"
dependencies = [
"bumble @ git+ssh://git@ssh.pstruebi.xyz:222/auracaster/bumble_mirror.git@12bcdb7770c0d57a094bc0a96cd52e701f97fece",
"lc3 @ git+ssh://git@ssh.pstruebi.xyz:222/auracaster/liblc3.git@7558637303106c7ea971e7bb8cedf379d3e08bcc",
"sounddevice",
"aioconsole",
"fastapi==0.115.11",
"uvicorn==0.34.0",
"aiohttp==3.9.3",
"sounddevice (>=0.5.1,<0.6.0)",
"aioconsole (>=0.8.1,<0.9.0)"
"aioconsole (>=0.8.1,<0.9.0)",
"numpy (>=2.2.6,<3.0.0)",
"streamlit (>=1.45.1,<2.0.0)",
"aiortc (>=1.13.0,<2.0.0)",
"sounddevice (>=0.5.2,<0.6.0)",
"python-dotenv (>=1.1.1,<2.0.0)"
]
[project.optional-dependencies]

View File

@@ -35,6 +35,10 @@ class AuracastGlobalConfig(BaseModel):
presentation_delay_us: int = 40000
# TODO:pydantic does not support bytes serialization - use .hex and np.fromhex()
manufacturer_data: tuple[int, bytes] | tuple[None, None] = (None, None)
# LE Audio: Broadcast Audio Immediate Rendering (metadata type 0x09)
# When true, include a zero-length LTV with type 0x09 in the subgroup metadata
# so receivers may render earlier than the presentation delay for lower latency.
immediate_rendering: bool = False
# "Audio input. "
# "'device' -> use the host's default sound input device, "
@@ -58,42 +62,52 @@ class AuracastBigConfig(BaseModel):
class AuracastBigConfigDeu(AuracastBigConfig):
id: int = 12
random_address: str = 'F1:F1:F2:F3:F4:F5'
name: str = 'Broadcast0'
name: str = 'Hörsaal A'
language: str ='deu'
program_info: str = 'Announcements German'
audio_source: str = 'file:./testdata/announcement_de.wav'
program_info: str = 'Vorlesung DE'
audio_source: str = 'file:./testdata/wave_particle_5min_de.wav'
class AuracastBigConfigEng(AuracastBigConfig):
id: int = 123
random_address: str = 'F2:F1:F2:F3:F4:F5'
name: str = 'Broadcast1'
name: str = 'Lecture Hall A'
language: str ='eng'
program_info: str = 'Announcements English'
audio_source: str = 'file:./testdata/announcement_en.wav'
program_info: str = 'Lecture EN'
audio_source: str = 'file:./testdata/wave_particle_5min_en.wav'
class AuracastBigConfigFra(AuracastBigConfig):
id: int = 1234
random_address: str = 'F3:F1:F2:F3:F4:F5'
name: str = 'Broadcast2'
# French
name: str = 'Auditoire A'
language: str ='fra'
program_info: str = 'Announcements French'
audio_source: str = 'file:./testdata/announcement_fr.wav'
program_info: str = 'Auditoire FR'
audio_source: str = 'file:./testdata/wave_particle_5min_fr.wav'
class AuracastBigConfigSpa(AuracastBigConfig):
id: int =12345
random_address: str = 'F4:F1:F2:F3:F4:F5'
name: str = 'Broadcast3'
name: str = 'Auditorio A'
language: str ='spa'
program_info: str = 'Announcements Spanish'
audio_source: str = 'file:./testdata/announcement_es.wav'
program_info: str = 'Auditorio ES'
audio_source: str = 'file:./testdata/wave_particle_5min_es.wav'
class AuracastBigConfigIta(AuracastBigConfig):
id: int =1234567
random_address: str = 'F5:F1:F2:F3:F4:F5'
name: str = 'Broadcast4'
name: str = 'Aula A'
language: str ='ita'
program_info: str = 'Announcements Italian'
audio_source: str = 'file:./testdata/announcement_it.wav'
program_info: str = 'Aula IT'
audio_source: str = 'file:./testdata/wave_particle_5min_it.wav'
class AuracastBigConfigPol(AuracastBigConfig):
id: int =12345678
random_address: str = 'F6:F1:F2:F3:F4:F5'
name: str = 'Sala Wykładowa'
language: str ='pol'
program_info: str = 'Sala Wykładowa PL'
audio_source: str = 'file:./testdata/wave_particle_5min_pl.wav'
class AuracastConfigGroup(AuracastGlobalConfig):

View File

@@ -44,13 +44,18 @@ from bumble.profiles import bass
import bumble.device
import bumble.transport
import bumble.utils
import numpy as np # for audio down-mix
from bumble.device import Host, BIGInfoAdvertisement, AdvertisingChannelMap
from bumble.audio import io as audio_io
from auracast import auracast_config
from auracast.utils.read_lc3_file import read_lc3_file
from auracast.utils.network_audio_receiver import NetworkAudioReceiverUncoded
from auracast.utils.webrtc_audio_input import WebRTCAudioInput
# Instantiate WebRTC audio input for streaming (can be used per-BIG or globally)
# modified from bumble
class ModWaveAudioInput(audio_io.ThreadedAudioInput):
"""Audio input that reads PCM samples from a .wav file."""
@@ -148,6 +153,30 @@ async def init_broadcast(
bap_sampling_freq = getattr(bap.SamplingFrequency, f"FREQ_{global_config.auracast_sampling_rate_hz}")
bigs = {}
for i, conf in enumerate(big_config):
metadata=le_audio.Metadata(
[
le_audio.Metadata.Entry(
tag=le_audio.Metadata.Tag.LANGUAGE, data=conf.language.encode()
),
le_audio.Metadata.Entry(
tag=le_audio.Metadata.Tag.PROGRAM_INFO, data=conf.program_info.encode()
),
le_audio.Metadata.Entry(
tag=le_audio.Metadata.Tag.BROADCAST_NAME, data=conf.name.encode()
),
]
+ (
[
# Broadcast Audio Immediate Rendering flag (type 0x09), zero-length value
le_audio.Metadata.Entry(tag = le_audio.Metadata.Tag.BROADCAST_AUDIO_IMMEDIATE_RENDERING_FLAG, data=b"")
]
if global_config.immediate_rendering #TODO: verify this
else []
)
)
logging.info(
metadata.pretty_print("\n")
)
bigs[f'big{i}'] = {}
# Config advertising set
bigs[f'big{i}']['basic_audio_announcement'] = bap.BasicAudioAnnouncement(
@@ -160,19 +189,7 @@ async def init_broadcast(
frame_duration=bap.FrameDuration.DURATION_10000_US,
octets_per_codec_frame=global_config.octets_per_frame,
),
metadata=le_audio.Metadata(
[
le_audio.Metadata.Entry(
tag=le_audio.Metadata.Tag.LANGUAGE, data=conf.language.encode()
),
le_audio.Metadata.Entry(
tag=le_audio.Metadata.Tag.PROGRAM_INFO, data=conf.program_info.encode()
),
le_audio.Metadata.Entry(
tag=le_audio.Metadata.Tag.BROADCAST_NAME, data=conf.name.encode()
),
]
),
metadata=metadata,
bis=[
bap.BasicAudioAnnouncement.BIS(
index=1,
@@ -211,7 +228,7 @@ async def init_broadcast(
primary_advertising_interval_max=200,
advertising_sid=i,
primary_advertising_phy=hci.Phy.LE_1M, # 2m phy config throws error - because for primary advertising channels, 1mbit is only supported
secondary_advertising_phy=hci.Phy.LE_2M, # this is the secondary advertising beeing send on non advertising channels (extendend advertising)
secondary_advertising_phy=hci.Phy.LE_1M, # this is the secondary advertising beeing send on non advertising channels (extendend advertising)
#advertising_tx_power= # tx power in dbm (max 20)
#secondary_advertising_max_skip=10,
),
@@ -272,7 +289,7 @@ async def init_broadcast(
logging.debug(f'big{i} parameters are:')
logging.debug('%s', pprint.pformat(vars(big)))
logging.debug(f'Finished setup of big{i}.')
logging.info(f'Finished setup of big{i}.')
await asyncio.sleep(i+1) # Wait for advertising to set up
@@ -322,26 +339,75 @@ class Streamer():
else:
logging.warning('Streamer is already running')
def stop_streaming(self):
"""Stops the background task if running."""
if self.is_streaming:
self.is_streaming = False
if self.task:
self.task.cancel() # Cancel the task safely
self.task = None
async def stop_streaming(self):
"""Gracefully stop streaming and release audio devices."""
if not self.is_streaming and self.task is None:
return
# Ask the streaming loop to finish
self.is_streaming = False
if self.task is not None:
self.task.cancel()
self.task = None
# Close audio inputs (await to ensure ALSA devices are released)
close_tasks = []
for big in self.bigs.values():
ai = big.get("audio_input")
if ai and hasattr(ai, "close"):
close_tasks.append(ai.close())
# Remove reference so a fresh one is created next time
big.pop("audio_input", None)
if close_tasks:
await asyncio.gather(*close_tasks, return_exceptions=True)
async def stream(self):
bigs = self.bigs
big_config = self.big_config
global_config = self.global_config
# init
for i, big in enumerate(bigs.values()):
audio_source = big_config[i].audio_source
input_format = big_config[i].input_format
# --- New: network_uncoded mode using NetworkAudioReceiver ---
if isinstance(audio_source, NetworkAudioReceiverUncoded):
# Start the UDP receiver coroutine so packets are actually received
asyncio.create_task(audio_source.receive())
encoder = lc3.Encoder(
frame_duration_us=global_config.frame_duration_us,
sample_rate_hz=global_config.auracast_sampling_rate_hz,
num_channels=1,
input_sample_rate_hz=audio_source.samplerate,
)
lc3_frame_samples = encoder.get_frame_samples()
big['pcm_bit_depth'] = 16
big['lc3_frame_samples'] = lc3_frame_samples
big['lc3_bytes_per_frame'] = global_config.octets_per_frame
big['audio_input'] = audio_source
big['encoder'] = encoder
big['precoded'] = False
elif audio_source == 'webrtc':
big['audio_input'] = WebRTCAudioInput()
encoder = lc3.Encoder(
frame_duration_us=global_config.frame_duration_us,
sample_rate_hz=global_config.auracast_sampling_rate_hz,
num_channels=1,
input_sample_rate_hz=48000, # TODO: get samplerate from webrtc
)
lc3_frame_samples = encoder.get_frame_samples()
big['pcm_bit_depth'] = 16
big['lc3_frame_samples'] = lc3_frame_samples
big['lc3_bytes_per_frame'] = global_config.octets_per_frame
big['encoder'] = encoder
big['precoded'] = False
# precoded lc3 from ram
if isinstance(big_config[i].audio_source, bytes):
elif isinstance(big_config[i].audio_source, bytes):
big['precoded'] = True
big['lc3_bytes_per_frame'] = global_config.octets_per_frame
lc3_frames = iter(big_config[i].audio_source)
@@ -352,6 +418,7 @@ class Streamer():
# precoded lc3 file
elif big_config[i].audio_source.endswith('.lc3'):
big['precoded'] = True
big['lc3_bytes_per_frame'] = global_config.octets_per_frame
filename = big_config[i].audio_source.replace('file:', '')
lc3_bytes = read_lc3_file(filename)
@@ -363,21 +430,23 @@ class Streamer():
# use wav files and code them entirely before streaming
elif big_config[i].precode_wav and big_config[i].audio_source.endswith('.wav'):
logging.info('Precoding wav file: %s, this may take a while', big_config[i].audio_source)
big['precoded'] = True
big['lc3_bytes_per_frame'] = global_config.octets_per_frame
audio_input = await audio_io.create_audio_input(audio_source, input_format)
audio_input.rewind = False
pcm_format = await audio_input.open()
if pcm_format.channels != 1:
print("Only 1 channels PCM configurations are supported")
logging.error("Only 1 channels PCM configurations are supported")
return
if pcm_format.sample_type == audio_io.PcmFormat.SampleType.INT16:
pcm_bit_depth = 16
elif pcm_format.sample_type == audio_io.PcmFormat.SampleType.FLOAT32:
pcm_bit_depth = None
else:
print("Only INT16 and FLOAT32 sample types are supported")
logging.error("Only INT16 and FLOAT32 sample types are supported")
return
encoder = lc3.Encoder(
frame_duration_us=global_config.frame_duration_us,
@@ -402,40 +471,92 @@ class Streamer():
# anything else, e.g. realtime stream from device (bumble)
else:
audio_input = await audio_io.create_audio_input(audio_source, input_format)
audio_input.rewind = big_config[i].loop
pcm_format = await audio_input.open()
# Store early so stop_streaming can close even if open() fails
big['audio_input'] = audio_input
# SoundDeviceAudioInput (used for `mic:<device>` captures) has no `.rewind`.
if hasattr(audio_input, "rewind"):
audio_input.rewind = big_config[i].loop
#try:
if pcm_format.channels != 1:
print("Only 1 channels PCM configurations are supported")
# Retry logic ALSA sometimes keeps the device busy for a short time after the
# previous stream has closed. Handle PortAudioError -9985 with back-off retries.
import sounddevice as _sd
max_attempts = 3
for attempt in range(1, max_attempts + 1):
try:
pcm_format = await audio_input.open()
break # success
except _sd.PortAudioError as err:
# -9985 == paDeviceUnavailable
logging.error('Could not open audio device %s with error %s', audio_source, err)
code = None
if hasattr(err, 'errno'):
code = err.errno
elif len(err.args) > 1 and isinstance(err.args[1], int):
code = err.args[1]
if code == -9985 and attempt < max_attempts:
backoff_ms = 200 * attempt
logging.warning("PortAudio device busy (attempt %d/%d). Retrying in %.1f ms…", attempt, max_attempts, backoff_ms)
# ensure device handle and PortAudio context are closed before retrying
try:
if hasattr(audio_input, "aclose"):
await audio_input.aclose()
elif hasattr(audio_input, "close"):
audio_input.close()
except Exception:
pass
# Fully terminate PortAudio to drop lingering handles (sounddevice quirk)
if hasattr(_sd, "_terminate"):
try:
_sd._terminate()
except Exception:
pass
# Small pause then re-initialize PortAudio
await asyncio.sleep(0.1)
if hasattr(_sd, "_initialize"):
try:
_sd._initialize()
except Exception:
pass
# Back-off before next attempt
await asyncio.sleep(backoff_ms / 1000)
# Recreate audio_input fresh for next attempt
audio_input = await audio_io.create_audio_input(audio_source, input_format)
continue
# Other errors or final attempt re-raise so caller can abort gracefully
raise
else:
# Loop exhausted without break
logging.error("Unable to open audio device after %d attempts giving up", max_attempts)
return
if pcm_format.channels != 1:
logging.info("Input device provides %d channels will down-mix to mono for LC3", pcm_format.channels)
if pcm_format.sample_type == audio_io.PcmFormat.SampleType.INT16:
pcm_bit_depth = 16
elif pcm_format.sample_type == audio_io.PcmFormat.SampleType.FLOAT32:
pcm_bit_depth = None
else:
print("Only INT16 and FLOAT32 sample types are supported")
logging.error("Only INT16 and FLOAT32 sample types are supported")
return
encoder = lc3.Encoder(
frame_duration_us=global_config.frame_duration_us,
sample_rate_hz=global_config.auracast_sampling_rate_hz,
num_channels=1,
input_sample_rate_hz=pcm_format.sample_rate,
)
lc3_frame_samples = encoder.get_frame_samples() # number of the pcm samples per lc3 frame
lc3_frame_samples = encoder.get_frame_samples() # number of the pcm samples per lc3 frame
big['pcm_bit_depth'] = pcm_bit_depth
big['channels'] = pcm_format.channels
big['lc3_frame_samples'] = lc3_frame_samples
big['lc3_bytes_per_frame'] = global_config.octets_per_frame
big['audio_input'] = audio_input
big['encoder'] = encoder
big['precoded'] = False
# Need for coded an uncoded audio
lc3_frame_size = global_config.octets_per_frame #encoder.get_frame_bytes(bitrate)
lc3_bytes_per_frame = lc3_frame_size #* 2 #multiplied by number of channels
big['lc3_bytes_per_frame'] = lc3_bytes_per_frame
# TODO: Maybe do some pre buffering so the stream is stable from the beginning. One half iso queue would be appropriate
logging.info("Streaming audio...")
bigs = self.bigs
self.is_streaming = True
@@ -443,7 +564,6 @@ class Streamer():
while self.is_streaming:
stream_finished = [False for _ in range(len(bigs))]
for i, big in enumerate(bigs.values()):
if big['precoded']:# everything was already lc3 coded beforehand
lc3_frame = bytes(
itertools.islice(big['lc3_frames'], big['lc3_bytes_per_frame'])
@@ -452,13 +572,26 @@ class Streamer():
if lc3_frame == b'': # Not all streams may stop at the same time
stream_finished[i] = True
continue
else:
else: # code lc3 on the fly
pcm_frame = await anext(big['audio_input'].frames(big['lc3_frame_samples']), None)
if pcm_frame is None: # Not all streams may stop at the same time
stream_finished[i] = True
continue
# Down-mix multi-channel PCM to mono for LC3 encoder if needed
if big.get('channels', 1) > 1:
if isinstance(pcm_frame, np.ndarray):
if pcm_frame.ndim > 1:
mono = pcm_frame.mean(axis=1).astype(pcm_frame.dtype)
pcm_frame = mono
else:
# Convert raw bytes to numpy, average channels, convert back
dtype = np.int16 if big['pcm_bit_depth'] == 16 else np.float32
samples = np.frombuffer(pcm_frame, dtype=dtype)
samples = samples.reshape(-1, big['channels']).mean(axis=1)
pcm_frame = samples.astype(dtype).tobytes()
lc3_frame = big['encoder'].encode(
pcm_frame, num_bytes=big['lc3_bytes_per_frame'], bit_depth=big['pcm_bit_depth']
)
@@ -511,13 +644,12 @@ async def broadcast(global_conf: auracast_config.AuracastGlobalConfig, big_conf:
if __name__ == "__main__":
import os
logging.basicConfig( #export LOG_LEVEL=INFO
level=os.environ.get('LOG_LEVEL', logging.DEBUG),
logging.basicConfig( #export LOG_LEVEL=DEBUG
level=os.environ.get('LOG_LEVEL', logging.INFO),
format='%(module)s.py:%(lineno)d %(levelname)s: %(message)s'
)
os.chdir(os.path.dirname(__file__))
config = auracast_config.AuracastConfigGroup(
bigs = [
auracast_config.AuracastBigConfigDeu(),
@@ -537,15 +669,19 @@ if __name__ == "__main__":
#config.transport='serial:/dev/serial/by-id/usb-ZEPHYR_Zephyr_HCI_UART_sample_95A087EADB030B24-if00,115200,rtscts' #nrf52dongle hci_uart usb cdc
#config.transport='usb:2fe3:000b' #nrf52dongle hci_usb # TODO: iso packet over usb not supported
#config.transport= 'auto'
config.transport='serial:/dev/ttyAMA2,1000000,rtscts' # transport for raspberry pi
config.transport='serial:/dev/ttyAMA4,1000000,rtscts' # transport for raspberry pi
# TODO: encrypted streams are not working
for big in config.bigs: # TODO: encrypted streams are not working
for big in config.bigs:
#big.code = 'ff'*16 # returns hci/HCI_ENCRYPTION_MODE_NOT_ACCEPTABLE_ERROR
#big.code = '78 e5 dc f1 34 ab 42 bf c1 92 ef dd 3a fd 67 ae'
big.precode_wav = True
big.audio_source = big.audio_source.replace('.wav', '_10_16_32.lc3') #lc3 precoded files
big.audio_source = read_lc3_file(big.audio_source) # load files in advance
big.precode_wav = False
#big.audio_source = big.audio_source.replace('.wav', '_10_16_32.lc3') #lc3 precoded files
#big.audio_source = read_lc3_file(big.audio_source) # load files in advance
# --- Network_uncoded mode using NetworkAudioReceiver ---
#big.audio_source = NetworkAudioReceiverUncoded(port=50007, samplerate=16000, channels=1, chunk_size=1024)
# 16kHz works reliably with 3 streams
# 24kHz is only working with 2 streams - probably airtime constraint

View File

@@ -52,13 +52,19 @@ class Multicaster:
self.device = device
self.is_auracast_init = True
def start_streaming(self):
async def start_streaming(self):
"""Start streaming; if an old stream is running, stop it first to release audio devices."""
if self.streamer is not None:
await self.stop_streaming()
# Brief pause to ensure ALSA/PortAudio fully releases the input device
await asyncio.sleep(0.5)
self.streamer = multicast.Streamer(self.bigs, self.global_conf, self.big_conf)
self.streamer.start_streaming()
def stop_streaming(self):
async def stop_streaming(self):
if self.streamer is not None:
self.streamer.stop_streaming()
await self.streamer.stop_streaming()
self.streamer = None
async def reset(self):
@@ -66,18 +72,28 @@ class Multicaster:
self.__init__(self.global_conf, self.big_conf)
async def shutdown(self):
# Ensure streaming is fully stopped before tearing down Bluetooth resources
if self.streamer is not None:
await self.stop_streaming()
self.is_auracast_init = False
self. is_audio_init = False
self.is_audio_init = False
if self.bigs:
for big in self.bigs.values():
if big.get('audio_input'):
if hasattr(big['audio_input'], 'aclose'):
await big['audio_input'].aclose()
if self.device:
await self.device.stop_advertising()
if self.bigs:
for big in self.bigs.values():
if big['advertising_set']:
if big.get('advertising_set'):
await big['advertising_set'].stop()
await self.device_acm.__aexit__(None, None, None) # Manually triggering teardown
# example commandline ui
async def command_line_ui(caster: Multicaster):
while True:

View File

@@ -0,0 +1,155 @@
"""
multicast_script
=================
Loads environment variables from a .env file located next to this script
and configures the multicast broadcast. Only UPPERCASE keys are read.
Environment variables
---------------------
- LOG_LEVEL: Logging level for the script.
Default: INFO. Examples: DEBUG, INFO, WARNING, ERROR.
- INPUT: Select audio capture source.
Values:
- "usb" (default): first available USB input device.
- "aes67": select AES67 inputs. Two forms:
* INPUT=aes67 -> first available AES67 input.
* INPUT=aes67,<substr> -> case-insensitive substring match against
the device name, e.g. INPUT=aes67,8f6326.
- BROADCAST_NAME: Name of the broadcast (Auracast BIG name).
Default: "Broadcast0".
- PROGRAM_INFO: Free-text program/broadcast info.
Default: "Some Announcements".
- LANGUATE: ISO 639-3 language code used by config (intentional key name).
Default: "deu".
- PULSE_LATENCY_MSEC: Pulse/PipeWire latency hint in milliseconds.
Default: 3.
Examples (.env)
---------------
LOG_LEVEL=DEBUG
INPUT=aes67,8f6326
BROADCAST_NAME=MyBroadcast
PROGRAM_INFO="Live announcements"
LANGUATE=deu
"""
import logging
import os
import time
from dotenv import load_dotenv
from auracast import multicast
from auracast import auracast_config
from auracast.utils.sounddevice_utils import list_usb_pw_inputs, list_network_pw_inputs
if __name__ == "__main__":
logging.basicConfig( #export LOG_LEVEL=DEBUG
level=os.environ.get('LOG_LEVEL', logging.INFO),
format='%(module)s.py:%(lineno)d %(levelname)s: %(message)s'
)
os.chdir(os.path.dirname(__file__))
# Load .env located next to this script (only uppercase keys will be referenced)
load_dotenv(dotenv_path='.env')
os.environ.setdefault("PULSE_LATENCY_MSEC", "3")
usb_inputs = list_usb_pw_inputs()
logging.info("USB pw inputs:")
for i, d in usb_inputs:
logging.info(f"{i}: {d['name']} in={d['max_input_channels']}")
aes67_inputs = list_network_pw_inputs()
logging.info("AES67 pw inputs:")
for i, d in aes67_inputs:
logging.info(f"{i}: {d['name']} in={d['max_input_channels']}")
# Input selection (usb | aes67). Default to usb.
# Allows specifying an AES67 device by substring: INPUT=aes67,<substring>
# Example: INPUT=aes67,8f6326 will match a device name containing "8f6326".
input_env = os.environ.get('INPUT', 'usb') or 'usb'
parts = [p.strip() for p in input_env.split(',', 1)]
input_mode = (parts[0] or 'usb').lower()
iface_substr = (parts[1].lower() if len(parts) > 1 and parts[1] else None)
selected_dev = None
if input_mode == 'aes67':
if not aes67_inputs and not iface_substr:
# No AES67 inputs and no specific target -> fail fast
raise RuntimeError("No AES67 audio inputs found.")
if iface_substr:
# Loop until a matching AES67 input becomes available
while True:
current = list_network_pw_inputs()
sel = next(((i, d) for i, d in current if iface_substr in (d.get('name','').lower())), None)
if sel:
input_sel = sel[0]
selected_dev = sel[1]
logging.info(f"Selected AES67 input by match '{iface_substr}': index={input_sel}")
break
logging.info(f"Waiting for AES67 input matching '{iface_substr}'... retrying in 2s")
time.sleep(2)
else:
input_sel, selected_dev = aes67_inputs[0]
logging.info(f"Selected first AES67 input: index={input_sel}, device={selected_dev['name']}")
else:
if usb_inputs:
input_sel, selected_dev = usb_inputs[0]
logging.info(f"Selected first USB input: index={input_sel}, device={selected_dev['name']}")
else:
raise RuntimeError("No USB audio inputs found.")
TRANSPORT1 = 'serial:/dev/ttyAMA3,1000000,rtscts' # transport for raspberry pi gpio header
TRANSPORT2 = 'serial:/dev/ttyAMA4,1000000,rtscts' # transport for raspberry pi gpio header
# Capture at 48 kHz to avoid PipeWire resampler latency; encode LC3 at 24 kHz
CAPTURE_SRATE = 48000
LC3_SRATE = 24000
OCTETS_PER_FRAME=60
# Read uppercase-only settings from environment/.env
broadcast_name = os.environ.get('BROADCAST_NAME', 'Broadcast0')
program_info = os.environ.get('PROGRAM_INFO', 'Some Announcements')
# Note: 'LANGUATE' (typo) is intentionally used as requested, maps to config.language
language = os.environ.get('LANGUATE', 'deu')
# Determine capture channel count based on selected device (prefer up to 2)
try:
max_in = int((selected_dev or {}).get('max_input_channels', 1))
except Exception:
max_in = 1
channels = max(1, min(2, max_in))
config = auracast_config.AuracastConfigGroup(
bigs = [
auracast_config.AuracastBigConfig(
name=broadcast_name,
program_info=program_info,
language=language,
iso_que_len=1,
audio_source=f'device:{input_sel}',
input_format=f"int16le,{CAPTURE_SRATE},{channels}",
sampling_frequency=LC3_SRATE,
octets_per_frame=OCTETS_PER_FRAME,
),
#auracast_config.AuracastBigConfigEng(),
],
immediate_rendering=True,
presentation_delay_us=40000,
qos_config=auracast_config.AuracastQosHigh(),
auracast_sampling_rate_hz = LC3_SRATE,
octets_per_frame = OCTETS_PER_FRAME, # 32kbps@16kHz
transport=TRANSPORT1
)
#config.debug = True
multicast.run_async(
multicast.broadcast(
config,
config.bigs
)
)

View File

@@ -1,104 +0,0 @@
import glob
import logging as log
from fastapi import FastAPI, HTTPException
from auracast import multicast_control, auracast_config
app = FastAPI()
# Initialize global configuration
global_config_group = auracast_config.AuracastConfigGroup()
# Create multicast controller
multicaster: multicast_control.Multicaster | None = None
@app.post("/init")
async def initialize(conf: auracast_config.AuracastConfigGroup):
"""Initializes the broadcasters."""
global global_config_group
global multicaster
try:
if conf.transport == 'auto':
serial_devices = glob.glob('/dev/serial/by-id/*')
log.info('Found serial devices: %s', serial_devices)
for device in serial_devices:
if 'usb-ZEPHYR_Zephyr_HCI_UART_sample' in device:
log.info('Using: %s', device)
conf.transport = f'serial:{device},115200,rtscts'
break
# check again if transport is still auto
if conf.transport == 'auto':
HTTPException(status_code=500, detail='No suitable transport found.')
# initialize the streams dict
global_config_group = conf
log.info(
'Initializing multicaster with config:\n %s', conf.model_dump_json(indent=2)
)
multicaster = multicast_control.Multicaster(
conf,
conf.bigs,
)
await multicaster.init_broadcast()
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/stream_lc3")
async def send_audio(audio_data: dict[str, str]):
"""Streams pre-coded LC3 audio."""
if multicaster is None:
raise HTTPException(status_code=500, detail='Auracast endpoint was never intialized')
try:
for big in global_config_group.bigs:
assert big.language in audio_data, HTTPException(status_code=500, detail='language len missmatch')
log.info('Received a send audio request for %s', big.language)
big.audio_source = audio_data[big.language].encode('latin-1') # TODO: use base64 encoding
multicaster.big_conf = global_config_group.bigs
multicaster.start_streaming()
return {"status": "audio_sent"}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/shutdown")
async def shutdown():
"""Stops broadcasting."""
try:
await multicaster.reset()
return {"status": "stopped"}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/stop_audio")
async def stop_audio():
"""Stops streaming."""
try:
multicaster.stop_streaming()
return {"status": "stopped"}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.get("/status")
async def get_status():
"""Gets the current status of the multicaster."""
if multicaster:
return multicaster.get_status()
else:
return {
'is_initialized': False,
'is_streaming': False,
}
if __name__ == '__main__':
import uvicorn
log.basicConfig(
level=log.INFO,
format='%(module)s.py:%(lineno)d %(levelname)s: %(message)s'
)
uvicorn.run(app, host="0.0.0.0", port=5000)

View File

@@ -0,0 +1,30 @@
-----BEGIN CERTIFICATE-----
MIIFDzCCAvegAwIBAgIUJkOMN61fArjxyeFLR1u2PnoUCogwDQYJKoZIhvcNAQEL
BQAwFzEVMBMGA1UEAwwMU3VtbWl0V2F2ZUNBMB4XDTI1MDYyOTE0MzMyOVoXDTQ1
MDYyNDE0MzMyOVowFzEVMBMGA1UEAwwMU3VtbWl0V2F2ZUNBMIICIjANBgkqhkiG
9w0BAQEFAAOCAg8AMIICCgKCAgEAnU1k3Yasc2mFCHNdBzf76Y7NTUIy50577fJL
kVVjwxUsUzimj6hkeTd16o5EsmTpW19/N8o/JJ1j3ne37EB8vm0q9H7yyN0fx+Gy
uJujmqu5ZG9+ow+kbqpxJUbRMmDkZqsF6/XHfNMUQLK5vVH219xmW/hgxdEB4o50
jF25+jhUuolVYybhLT9AGtXhpqExmCn/o78I97+GtYNdkY8cwCt/khftM4DRDeEA
NdyVUWHG2sWqgx0BpgyL9gH/YwfeqBrjFmhh1VbgPCdgypwRV6YHVUqPtmSL7H7q
CmX8/ccyS6Cif9z/rsb1KwSeOgNKqV3D5DN3Qrboy9NmbWKXmhnF3Pl0EQ5f2/WS
xN+NKo8LNyZErQ27jZ6Xn9rVBRQ4rTw5oVf5hi6bOZcW2GNIQhQomQy83ohwFDnW
6aLsBag4/lGJFS+QpRAwIvFY4R559Ki3xndUQpvbt0KHIUNTlWddACm1tkcgXEGF
GJRZMBcKlyNdM5cRjhMtuZljoY2nHdfouiy4SETHgFFVvIZ2uOZLikljkL7cnWqF
0DZh9MxIZqZEoffSDRCRdlhmPITwuacGTFBNAmiGqg463rNmzcyc5JOoPUQrcSy1
0F5Ig16tiGjpgNtqyBen0r0udEoU1bBF/kxhAQCbam/IqpTtR+ouRnnbE4ST2zV6
IXc4mPcCAwEAAaNTMFEwHQYDVR0OBBYEFOsaKvMh7Lr+/O620X2uzHxlnvmzMB8G
A1UdIwQYMBaAFOsaKvMh7Lr+/O620X2uzHxlnvmzMA8GA1UdEwEB/wQFMAMBAf8w
DQYJKoZIhvcNAQELBQADggIBAAxq7hy9xgDCxwZOQX8Z5iBIK3agXUads7m91TcH
/EzFfJsUDOpDsSi58wIXuCmiJ+qe2S+hJxghLNsqm01DosPuNLNI0gCDg+glx5z5
ADtY0EJb7mRH+xuFC1GBdP7ve3REvfi7WC9snrqBUji/xL4VycaOyTDGOxWaHlyZ
u876I6/+xkj5hkhM1bsbEcGZ81QnTaJyeVtHTRYaORPAb2FP2V65MTn18Pu08i4T
bzh0KAsoDkwKvoEK24T5xFEUCuLexQ+6fabYXGro3It9VmAbrtkSyX8Z1eO7rVCu
hsUrA6UDzTerX1pWafeftpKiH7YiOaYYOAVcqDn+WKwYq3MPafNJp8x8HV1eeWYD
dx9HBKuvlOsoxnjJMnYusmQZyJk1EJR03najrV7HH8cyU2gfNyBwfsr6nU+FnDOX
qL2P0nWDjBkfjQRvmG59YLDVZYhw30+lishpmMLGZGwRFCjMCHD7rAdQTB3dtCP6
NqaGogwitIdIITBtyV1ZABoE3vQuUAKZChU+DsSKniyFitKDQrXP+rwcX5Y5/pS1
S1s6ITgllbErKqAoeelEVkJyiWykEtrtdcD0DXTr/QY4GzXeMi9u+dMXUOt95Md2
lQVAaFIX8QxbmHXen6GsXeHhPpPw8sXtC6rh7aqSCqqB6EDS77mjrGHXbSeBS5aq
MklC
-----END CERTIFICATE-----

Binary file not shown.

View File

@@ -0,0 +1,30 @@
-----BEGIN CERTIFICATE-----
MIIFDzCCAvegAwIBAgIUJkOMN61fArjxyeFLR1u2PnoUCogwDQYJKoZIhvcNAQEL
BQAwFzEVMBMGA1UEAwwMU3VtbWl0V2F2ZUNBMB4XDTI1MDYyOTE0MzMyOVoXDTQ1
MDYyNDE0MzMyOVowFzEVMBMGA1UEAwwMU3VtbWl0V2F2ZUNBMIICIjANBgkqhkiG
9w0BAQEFAAOCAg8AMIICCgKCAgEAnU1k3Yasc2mFCHNdBzf76Y7NTUIy50577fJL
kVVjwxUsUzimj6hkeTd16o5EsmTpW19/N8o/JJ1j3ne37EB8vm0q9H7yyN0fx+Gy
uJujmqu5ZG9+ow+kbqpxJUbRMmDkZqsF6/XHfNMUQLK5vVH219xmW/hgxdEB4o50
jF25+jhUuolVYybhLT9AGtXhpqExmCn/o78I97+GtYNdkY8cwCt/khftM4DRDeEA
NdyVUWHG2sWqgx0BpgyL9gH/YwfeqBrjFmhh1VbgPCdgypwRV6YHVUqPtmSL7H7q
CmX8/ccyS6Cif9z/rsb1KwSeOgNKqV3D5DN3Qrboy9NmbWKXmhnF3Pl0EQ5f2/WS
xN+NKo8LNyZErQ27jZ6Xn9rVBRQ4rTw5oVf5hi6bOZcW2GNIQhQomQy83ohwFDnW
6aLsBag4/lGJFS+QpRAwIvFY4R559Ki3xndUQpvbt0KHIUNTlWddACm1tkcgXEGF
GJRZMBcKlyNdM5cRjhMtuZljoY2nHdfouiy4SETHgFFVvIZ2uOZLikljkL7cnWqF
0DZh9MxIZqZEoffSDRCRdlhmPITwuacGTFBNAmiGqg463rNmzcyc5JOoPUQrcSy1
0F5Ig16tiGjpgNtqyBen0r0udEoU1bBF/kxhAQCbam/IqpTtR+ouRnnbE4ST2zV6
IXc4mPcCAwEAAaNTMFEwHQYDVR0OBBYEFOsaKvMh7Lr+/O620X2uzHxlnvmzMB8G
A1UdIwQYMBaAFOsaKvMh7Lr+/O620X2uzHxlnvmzMA8GA1UdEwEB/wQFMAMBAf8w
DQYJKoZIhvcNAQELBQADggIBAAxq7hy9xgDCxwZOQX8Z5iBIK3agXUads7m91TcH
/EzFfJsUDOpDsSi58wIXuCmiJ+qe2S+hJxghLNsqm01DosPuNLNI0gCDg+glx5z5
ADtY0EJb7mRH+xuFC1GBdP7ve3REvfi7WC9snrqBUji/xL4VycaOyTDGOxWaHlyZ
u876I6/+xkj5hkhM1bsbEcGZ81QnTaJyeVtHTRYaORPAb2FP2V65MTn18Pu08i4T
bzh0KAsoDkwKvoEK24T5xFEUCuLexQ+6fabYXGro3It9VmAbrtkSyX8Z1eO7rVCu
hsUrA6UDzTerX1pWafeftpKiH7YiOaYYOAVcqDn+WKwYq3MPafNJp8x8HV1eeWYD
dx9HBKuvlOsoxnjJMnYusmQZyJk1EJR03najrV7HH8cyU2gfNyBwfsr6nU+FnDOX
qL2P0nWDjBkfjQRvmG59YLDVZYhw30+lishpmMLGZGwRFCjMCHD7rAdQTB3dtCP6
NqaGogwitIdIITBtyV1ZABoE3vQuUAKZChU+DsSKniyFitKDQrXP+rwcX5Y5/pS1
S1s6ITgllbErKqAoeelEVkJyiWykEtrtdcD0DXTr/QY4GzXeMi9u+dMXUOt95Md2
lQVAaFIX8QxbmHXen6GsXeHhPpPw8sXtC6rh7aqSCqqB6EDS77mjrGHXbSeBS5aq
MklC
-----END CERTIFICATE-----

View File

@@ -0,0 +1 @@
5078804E6FBCF893D5537715FD928E46AD576ECA

View File

@@ -0,0 +1,52 @@
-----BEGIN PRIVATE KEY-----
MIIJQQIBADANBgkqhkiG9w0BAQEFAASCCSswggknAgEAAoICAQCdTWTdhqxzaYUI
c10HN/vpjs1NQjLnTnvt8kuRVWPDFSxTOKaPqGR5N3XqjkSyZOlbX383yj8knWPe
d7fsQHy+bSr0fvLI3R/H4bK4m6Oaq7lkb36jD6RuqnElRtEyYORmqwXr9cd80xRA
srm9UfbX3GZb+GDF0QHijnSMXbn6OFS6iVVjJuEtP0Aa1eGmoTGYKf+jvwj3v4a1
g12RjxzAK3+SF+0zgNEN4QA13JVRYcbaxaqDHQGmDIv2Af9jB96oGuMWaGHVVuA8
J2DKnBFXpgdVSo+2ZIvsfuoKZfz9xzJLoKJ/3P+uxvUrBJ46A0qpXcPkM3dCtujL
02ZtYpeaGcXc+XQRDl/b9ZLE340qjws3JkStDbuNnpef2tUFFDitPDmhV/mGLps5
lxbYY0hCFCiZDLzeiHAUOdbpouwFqDj+UYkVL5ClEDAi8VjhHnn0qLfGd1RCm9u3
QochQ1OVZ10AKbW2RyBcQYUYlFkwFwqXI10zlxGOEy25mWOhjacd1+i6LLhIRMeA
UVW8hna45kuKSWOQvtydaoXQNmH0zEhmpkSh99INEJF2WGY8hPC5pwZMUE0CaIaq
Djres2bNzJzkk6g9RCtxLLXQXkiDXq2IaOmA22rIF6fSvS50ShTVsEX+TGEBAJtq
b8iqlO1H6i5GedsThJPbNXohdziY9wIDAQABAoICAER+VSuyfve4HCGsXfcNNQcj
U5jO+OxH++WFqcrsJAbnesf39Gq8N5eigxkxfo8xKn1LbVElIu52C+zsMy1PfSHL
1jbk6iF1S2fVCmWg+5GXMaAefkVRQ9eeJqtFFUU69GkSEf+HIyhinsB3MjJR9MpU
YUutsLGiCxCT2ALgsuDV02rv7rrATK9PicHFnL5aFQa9Tt+FiMmb33O88iq15p50
slUyTuosrpq8/ML3PBtWGGjdRhxWLogXkX/6qbH81MJdBsGUjPkAnZ4DxX0jjNed
5zaHw2D3kgfV0WHau9ji+i79EJTdbYW0gz+KgL0g/ssVlX0Rvd3SWDacY87AbeMQ
b1Tl3iOXqt6nqHupxgWthAnrc81bz0NrabmKCnWCQLlYiuvJ+hN945H4uzjVh5Tx
PS0Nf17zTZsrWQgkz/ei4SIQtg/3lBm70BSsSpu+JtFJ8P+SB64maqAhhaF4mlEk
SA5cNaY+TKTO9up3aUWnYi/GFV2R3l+wTuNiC4QDmFZRWA4RrM0EK1HrhE+5fnxJ
cPBU48QB+IrZOI0qoqd/8XxHyEe/qzJ7Ml7wLBMzPOyr9ST6PSmoDQrT4mxeHAVE
ogfjJ5LjaY4kyJp/u5LsvhzF6sS5InvME2YnXXAb4nvxohPFFKY9iWDZ3W+jN6xD
zQ40bdQDVZW6fXC+HbLBAoIBAQDQkmZYb6JhrZzSQbXoQOFevPi2odeqGCqgwLAD
fp7ZMQisQYpcXmZtyBOWX8ZO+1O5KtXEFsdf+97rwOqMWVDmd2Q2VMSmW++Ni4U8
HZvV2gfYZISds2PXtWVLF9UNuXZ+a+HPPDpqKenyaLJtMvr1xX2kBRsi1CMk6yLI
tCIwh4rnDiYJYHrmIggP/w1YllCkM5k33OeFuzPnW2rY0z+Q260Cxr3ouktWJ4tz
U7vssrZh3LtvWXvkSh7mbotON6YUXpeX2WV/E/7Kh/bm8uLZGuYVhHctvjUmYpA2
LFk6i3Mulh0OHab3WcOQV+Dpcut6QBvS6aJsxYh/tWIsn3M3AoIBAQDBEnAzEZ2S
cpOoXSKOaYpoQ7wnwRONckJ2FKYKA7anRX4FTW6d3E2Jb/+CmXqzcRWWSvNt7ab/
N+fXVLi1Nc2fC5BI0hFEVvPwp9mnMH8HCG7QcHQAhjYaKS1QeCEyLCudzcNBXoR9
OuKTQcJd9tX0oJj6GNuY76gmxH3Smgwim2fPsHX0A2kekpyqVS3zHo47oeUO0N/Q
WWNcQ49+9T2KZXF116rjL1TDZkUHvGi6p1wSAc/J5ixQ6EagfJ72PujGBkpRTTiR
Fl/Qp4Ldy7S7AzOeiP3/w/0j5qL0NN0ZjUnoOr8u+1WaUyxTxN4+TZG3ThIYIAK1
UTs6VLz2gmhBAoIBABx2Dc89lIv9s+uhGeCSke5qnQnW9eX5HEAJazte2PBMV6Gh
4+6M1y9d4QZhFV+LvjYDWV5DuXsolJfZIGh8e6SnYB5l3NvSqdLH2iuE4tIAyZdG
yC3438P8tdDUdLdFupyvvgWYc2QvSgRRMx/hmAtXorhyFezfw9fy2jFHG29B37t9
28TlzH+A31bHeBvBj0mI3PyZgWJnVELa366szPzIbUh2tE2Atm0QQmA/aeJ31Jlw
FIeyT0ysrKDHLu1CfMBE1CzddpMruFYMza1gMYJswD7pb5XnYbtWMdWioZ5yjwop
Y9ecRj90mVImG8PfcbCh9OoIBakQH3tF1hq+u2sCggEATdST/FJGlgmwMnfQ/V3Y
WK2thM0Vh7ieyCEMyg6zK/0cjyCmzeZIL3ZBpzEdwIZ+sEZomVDrOAkeYbSafRpC
WLH9qQ1dvpHa5pGTcQ1gt8ITgd1DNg7kcmlVBhJXN3WM46FV690hRaZePgSNSPm/
SE0RPgiVRbKes3oUSrik2bKSB6xX8FULpDJwC04pJs+TgMCDqRRUlRXjswbdKs3L
0CWStnGJRuoGnnp0q2itQ0lCGVQ3omkyRi9MgVebcSLtDR7uCJY7jmlZmLBeVfDP
W3Av9+G7msY0HqvT1uQUmT9WotJDzbmtyXdr8Bz1hmIYsq87JhSJYvRrDtmoDyuE
wQKCAQBYY7G1HIhLEeS07UZ1yMxSCLZM3rLqEfpckzy68aA48HeiockwbtShky/8
D4pvSwZnTF9VWxXKaK46FuIULSUXc8eMSx2pCShQ5nFHa04j4ZjPwsWRrUc98AiO
pkbSgfxmKwAHpRBlimMleG+kXz6Urr5CJVQyWMP1hXTpGR1HME1z9ZbaACwvfMJk
0xCytMv3/m7JYiCfHRsc09sjHZQZtou0JpRczkxustxXL2wylvAjI4hNwYIl7Oj8
yzhhDzoqUGOA8uhyXZtG6NfPMr5pBo0J/pskaHco8UNV+gjOwewHrwd7K2NZmQQj
sKOYrVeRKuwd/MuNfkJTA8MOwLM4
-----END PRIVATE KEY-----

View File

@@ -0,0 +1,30 @@
#!/bin/bash
# Script to generate a CA cert/key and a device/server cert signed by this CA
# Outputs: ca_cert.pem, ca_key.pem, device_cert.pem, device_key.pem
CA_DIR=certs/ca
mkdir -p "$CA_DIR"
CA_CERT=$CA_DIR/ca_cert.pem
CA_KEY=$CA_DIR/ca_key.pem
# Generate CA key and cert (20 year expiry)
echo "Generating CA key and certificate (20 year expiry)..."
openssl req -x509 -newkey rsa:4096 -days 7300 -nodes -subj "/CN=SummitWaveCA" -keyout "$CA_KEY" -out "$CA_CERT"
# PEM version (for most browsers)
cp "$CA_CERT" "$CA_DIR/ca_cert.crt"
# DER version (for Windows)
openssl x509 -in "$CA_CERT" -outform der -out "$CA_DIR/ca_cert.der"
# Output summary
echo "CA cert: $CA_CERT"
echo "CA cert (CRT for browser import): $CA_DIR/ca_cert.crt"
echo "CA key: $CA_KEY"
echo "Distribute $CA_CERT or $CA_DIR/ca_cert.crt to clients to trust this device."
echo "Keep $CA_KEY secret and offline except when signing device CSRs."
echo "CA cert: $CA_CERT"
echo "CA cert (CRT for browser import): $CERT_DIR/ca_cert.crt"
echo "CA key: $CA_KEY"
echo "Device cert: $DEVICE_CERT"
echo "Device key: $DEVICE_KEY"
echo "Distribute $CA_CERT or $CERT_DIR/ca_cert.crt to clients to trust this device."

View File

@@ -0,0 +1,399 @@
# frontend/app.py
import os
import time
import streamlit as st
import requests
from auracast import auracast_config
import logging as log
# Track whether WebRTC stream is active across Streamlit reruns
if 'stream_started' not in st.session_state:
st.session_state['stream_started'] = False
# Global: desired packetization time in ms for Opus (should match backend)
PTIME = 40
BACKEND_URL = "http://localhost:5000"
#TRANSPORT1 = "serial:/dev/serial/by-id/usb-ZEPHYR_Zephyr_HCI_UART_sample_B53C372677E14460-if00,115200,rtscts"
#TRANSPORT2 = "serial:/dev/serial/by-id/usb-ZEPHYR_Zephyr_HCI_UART_sample_CC69A2912F84AE5E-if00,115200,rtscts"
TRANSPORT1 = 'serial:/dev/ttyAMA3,1000000,rtscts' # transport for raspberry pi gpio header
TRANSPORT2 = 'serial:/dev/ttyAMA4,1000000,rtscts' # transport for raspberry pi gpio header
QUALITY_MAP = {
"High (48kHz)": {"rate": 48000, "octets": 120},
"Good (32kHz)": {"rate": 32000, "octets": 80},
"Medium (24kHz)": {"rate": 24000, "octets": 60},
"Fair (16kHz)": {"rate": 16000, "octets": 40},
}
# Try loading persisted settings from backend
saved_settings = {}
try:
resp = requests.get(f"{BACKEND_URL}/status", timeout=1)
if resp.status_code == 200:
saved_settings = resp.json()
except Exception:
saved_settings = {}
st.title("🎙️ Auracast Audio Mode Control")
# Audio mode selection with persisted default
options = ["Webapp", "USB/Network", "Demo"]
saved_audio_mode = saved_settings.get("audio_mode", "Webapp")
if saved_audio_mode not in options:
saved_audio_mode = "Webapp"
audio_mode = st.selectbox(
"Audio Mode",
options,
index=options.index(saved_audio_mode),
help="Select the audio input source. Choose 'Webapp' for browser microphone, 'USB/Network' for a connected hardware device, or 'Demo' for a simulated stream."
)
if audio_mode == "Demo":
demo_stream_map = {
"1 × 48kHz": {"quality": "High (48kHz)", "streams": 1},
"2 × 24kHz": {"quality": "Medium (24kHz)", "streams": 2},
"3 × 16kHz": {"quality": "Fair (16kHz)", "streams": 3},
"2 × 48kHz": {"quality": "High (48kHz)", "streams": 2},
"4 × 24kHz": {"quality": "Medium (24kHz)", "streams": 4},
"6 × 16kHz": {"quality": "Fair (16kHz)", "streams": 6},
}
demo_options = list(demo_stream_map.keys())
default_demo = demo_options[0]
demo_selected = st.selectbox(
"Demo Stream Type",
demo_options,
index=0,
help="Select the demo stream configuration."
)
#st.info(f"Demo mode selected: {demo_selected} (Streams: {demo_stream_map[demo_selected]['streams']}, Rate: {demo_stream_map[demo_selected]['rate']} Hz)")
# Start/Stop buttons for demo mode
if 'demo_stream_started' not in st.session_state:
st.session_state['demo_stream_started'] = False
col1, col2 = st.columns(2)
with col1:
start_demo = st.button("Start Demo Stream")
with col2:
stop_demo = st.button("Stop Demo Stream")
if start_demo:
# Always stop any running stream for clean state
try:
requests.post(f"{BACKEND_URL}/stop_audio").json()
except Exception:
pass
time.sleep(1)
demo_cfg = demo_stream_map[demo_selected]
# Octets per frame logic matches quality_map
q = QUALITY_MAP[demo_cfg['quality']]
# Language configs and test files
lang_cfgs = [
(auracast_config.AuracastBigConfigDeu, 'de'),
(auracast_config.AuracastBigConfigEng, 'en'),
(auracast_config.AuracastBigConfigFra, 'fr'),
(auracast_config.AuracastBigConfigSpa, 'es'),
(auracast_config.AuracastBigConfigIta, 'it'),
(auracast_config.AuracastBigConfigPol, 'pl'),
]
bigs1 = []
for i in range(demo_cfg['streams']):
cfg_cls, lang = lang_cfgs[i % len(lang_cfgs)]
bigs1.append(cfg_cls(
audio_source=f'file:../testdata/wave_particle_5min_{lang}_{int(q["rate"]/1000)}kHz_mono.wav',
iso_que_len=32,
sampling_frequency=q['rate'],
octets_per_frame=q['octets'],
))
# Split bigs into two configs if needed
max_per_mc = {48000: 1, 24000: 2, 16000: 3}
max_streams = max_per_mc.get(q['rate'], 3)
bigs2 = []
if len(bigs1) > max_streams:
bigs2 = bigs1[max_streams:]
bigs1 = bigs1[:max_streams]
config1 = auracast_config.AuracastConfigGroup(
auracast_sampling_rate_hz=q['rate'],
octets_per_frame=q['octets'],
transport=TRANSPORT1,
bigs=bigs1
)
config2 = None
if bigs2:
config2 = auracast_config.AuracastConfigGroup(
auracast_sampling_rate_hz=q['rate'],
octets_per_frame=q['octets'],
transport=TRANSPORT2,
bigs=bigs2
)
# Call /init and /init2
try:
r1 = requests.post(f"{BACKEND_URL}/init", json=config1.model_dump())
if r1.status_code == 200:
msg = f"Demo stream started on multicaster 1 ({len(bigs1)} streams)"
st.session_state['demo_stream_started'] = True
st.success(msg)
else:
st.session_state['demo_stream_started'] = False
st.error(f"Failed to initialize multicaster 1: {r1.text}")
if config2:
r2 = requests.post(f"{BACKEND_URL}/init2", json=config2.model_dump())
if r2.status_code == 200:
st.success(f"Demo stream started on multicaster 2 ({len(bigs2)} streams)")
else:
st.error(f"Failed to initialize multicaster 2: {r2.text}")
except Exception as e:
st.session_state['demo_stream_started'] = False
st.error(f"Error: {e}")
elif stop_demo:
try:
r = requests.post(f"{BACKEND_URL}/stop_audio").json()
st.session_state['demo_stream_started'] = False
if r.get('was_running'):
st.info("Demo stream stopped.")
else:
st.info("Demo stream was not running.")
except Exception as e:
st.error(f"Error: {e}")
elif st.session_state['demo_stream_started']:
st.success(f"Demo stream running: {demo_selected}")
else:
st.info("Demo stream not running.")
quality = None # Not used in demo mode
else:
# Stream quality selection (now enabled)
quality_options = list(QUALITY_MAP.keys())
default_quality = "Medium (24kHz)" if "Medium (24kHz)" in quality_options else quality_options[0]
quality = st.selectbox(
"Stream Quality (Sampling Rate)",
quality_options,
index=quality_options.index(default_quality),
help="Select the audio sampling rate for the stream. Lower rates may improve compatibility."
)
default_name = saved_settings.get('channel_names', ["Broadcast0"])[0]
default_lang = saved_settings.get('languages', ["deu"])[0]
default_input = saved_settings.get('input_device') or 'default'
stream_name = st.text_input(
"Channel Name",
value=default_name,
help="The primary name for your broadcast. Like the SSID of a WLAN, it identifies your stream for receivers."
)
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
program_info = st.text_input(
"Program Info",
value=default_program_info,
help="Additional details about the broadcast program, such as its content or purpose. Shown to receivers for more context."
)
language = st.text_input(
"Language (ISO 639-3)",
value=default_lang,
help="Three-letter language code (e.g., 'eng' for English, 'deu' for German). Used by receivers to display the language of the stream. See: https://en.wikipedia.org/wiki/List_of_ISO_639-3_codes"
)
# Gain slider for Webapp mode
if audio_mode == "Webapp":
mic_gain = st.slider("Microphone Gain", 0.0, 2.0, 1.0, 0.1, help="Adjust microphone volume sent to Auracast")
else:
mic_gain = 1.0
# Input device selection for USB mode
if audio_mode == "USB/Network":
resp = requests.get(f"{BACKEND_URL}/audio_inputs")
device_list = resp.json().get('inputs', [])
# Display "name [id]" but use name as value
input_options = [f"{d['name']} [{d['id']}]" for d in device_list]
option_name_map = {f"{d['name']} [{d['id']}]": d['name'] for d in device_list}
device_names = [d['name'] for d in device_list]
# Determine default input by name
default_input_name = saved_settings.get('input_device')
if default_input_name not in device_names and device_names:
default_input_name = device_names[0]
default_input_label = None
for label, name in option_name_map.items():
if name == default_input_name:
default_input_label = label
break
if not input_options:
st.warning("No hardware audio input devices found. Plug in a USB input device and click Refresh.")
if st.button("Refresh"):
try:
requests.post(f"{BACKEND_URL}/refresh_audio_inputs", timeout=3)
except Exception as e:
st.error(f"Failed to refresh devices: {e}")
st.rerun()
input_device = None
else:
col1, col2 = st.columns([3, 1], vertical_alignment="bottom")
with col1:
selected_option = st.selectbox(
"Input Device",
input_options,
index=input_options.index(default_input_label) if default_input_label in input_options else 0
)
with col2:
if st.button("Refresh"):
try:
requests.post(f"{BACKEND_URL}/refresh_audio_inputs", timeout=3)
except Exception as e:
st.error(f"Failed to refresh devices: {e}")
st.rerun()
# Send only the device name to backend
input_device = option_name_map[selected_option] if selected_option in option_name_map else None
else:
input_device = None
start_stream = st.button("Start Auracast")
stop_stream = st.button("Stop Auracast")
# If gain slider moved while streaming, send update to JS without restarting
if audio_mode == "Webapp" and st.session_state.get('stream_started'):
update_js = f"""
<script>
if (window.gainNode) {{ window.gainNode.gain.value = {mic_gain}; }}
</script>
"""
st.components.v1.html(update_js, height=0)
if stop_stream:
st.session_state['stream_started'] = False
try:
r = requests.post(f"{BACKEND_URL}/stop_audio").json()
if r['was_running']:
st.success("Stream Stopped!")
else:
st.success("Stream was not running.")
except Exception as e:
st.error(f"Error: {e}")
# Ensure existing WebRTC connection is fully closed so that a fresh
# connection is created the next time we start the stream.
if audio_mode == "Webapp":
cleanup_js = """
<script>
if (window.webrtc_pc) {
window.webrtc_pc.getSenders().forEach(s => s.track.stop());
window.webrtc_pc.close();
window.webrtc_pc = null;
}
window.webrtc_started = false;
</script>
"""
st.components.v1.html(cleanup_js, height=0)
if start_stream:
# Always send stop to ensure backend is in a clean state, regardless of current status
r = requests.post(f"{BACKEND_URL}/stop_audio").json()
if r['was_running']:
st.success("Stream Stopped!")
# Small pause lets backend fully release audio devices before re-init
time.sleep(1)
# Prepare config using the model (do NOT send qos_config, only relevant fields)
q = QUALITY_MAP[quality]
config = auracast_config.AuracastConfigGroup(
auracast_sampling_rate_hz=q['rate'],
octets_per_frame=q['octets'],
transport=TRANSPORT1, # transport for raspberry pi gpio header
bigs = [
auracast_config.AuracastBigConfig(
name=stream_name,
program_info=program_info,
language=language,
audio_source=(
f"device:{input_device}" if audio_mode == "USB/Network" else (
"webrtc" if audio_mode == "Webapp" else "network"
)
),
input_format=(f"int16le,{q['rate']},1" if audio_mode == "USB/Network" else "auto"),
iso_que_len=1,
sampling_frequency=q['rate'],
octets_per_frame=q['octets'],
),
]
)
try:
r = requests.post(f"{BACKEND_URL}/init", json=config.model_dump())
if r.status_code == 200:
st.success("Stream Started!")
else:
st.error(f"Failed to initialize: {r.text}")
except Exception as e:
st.error(f"Error: {e}")
# Render / maintain WebRTC component
if audio_mode == "Webapp" and (start_stream or st.session_state.get('stream_started')):
st.markdown("Starting microphone; allow access if prompted and speak.")
component = f"""
<script>
(async () => {{
// Clean up any previous WebRTC connection before starting a new one
if (window.webrtc_pc) {{
window.webrtc_pc.getSenders().forEach(s => s.track.stop());
window.webrtc_pc.close();
}}
const GAIN_VALUE = {mic_gain};
const pc = new RTCPeerConnection(); // No STUN needed for localhost
window.webrtc_pc = pc;
window.webrtc_started = true;
const micStream = await navigator.mediaDevices.getUserMedia({{audio:true}});
// Create Web Audio gain processing
const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
const source = audioCtx.createMediaStreamSource(micStream);
const gainNode = audioCtx.createGain();
gainNode.gain.value = GAIN_VALUE;
// Expose for later adjustments
window.gainNode = gainNode;
const dest = audioCtx.createMediaStreamDestination();
source.connect(gainNode).connect(dest);
// Add processed tracks to WebRTC
dest.stream.getTracks().forEach(t => pc.addTrack(t, dest.stream));
// --- WebRTC offer/answer exchange ---
const offer = await pc.createOffer();
// Patch SDP offer to include a=ptime using global PTIME
let sdp = offer.sdp;
const ptime_line = 'a=ptime:{PTIME}';
const maxptime_line = 'a=maxptime:{PTIME}';
if (sdp.includes('a=sendrecv')) {{
sdp = sdp.replace('a=sendrecv', 'a=sendrecv\\n' + ptime_line + '\\n' + maxptime_line);
}} else {{
sdp += '\\n' + ptime_line + '\\n' + maxptime_line;
}}
const patched_offer = new RTCSessionDescription({{sdp, type: offer.type}});
await pc.setLocalDescription(patched_offer);
// Send offer to backend
const response = await fetch(
"{BACKEND_URL}/offer",
{{
method: 'POST',
headers: {{'Content-Type':'application/json'}},
body: JSON.stringify({{sdp: pc.localDescription.sdp, type: pc.localDescription.type}})
}}
);
const answer = await response.json();
await pc.setRemoteDescription(new RTCSessionDescription({{sdp: answer.sdp, type: answer.type}}));
}})();
</script>
"""
st.components.v1.html(component, height=0)
st.session_state['stream_started'] = True
#else:
# st.header("Advertised Streams (Cloud Announcements)")
# st.info("This feature requires backend support to list advertised streams.")
# Placeholder for future implementation
# Example: r = requests.get(f"{BACKEND_URL}/advertised_streams")
# if r.status_code == 200:
# streams = r.json()
# for s in streams:
# st.write(s)
# else:
# st.error("Could not fetch advertised streams.")
log.basicConfig(
level=os.environ.get('LOG_LEVEL', log.DEBUG),
format='%(module)s.py:%(lineno)d %(levelname)s: %(message)s'
)

View File

@@ -0,0 +1,421 @@
import glob
import os
import logging as log
import uuid
import json
import sys
from datetime import datetime
import asyncio
import numpy as np
from pydantic import BaseModel
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from auracast import multicast_control, auracast_config
from aiortc import RTCPeerConnection, RTCSessionDescription, MediaStreamTrack
import av
import av.audio.layout
import sounddevice as sd # type: ignore
from typing import Set, List, Dict, Any
import traceback
PTIME = 40 # TODO: seems to have no effect at all
pcs: Set[RTCPeerConnection] = set() # keep refs so they dont GC early
AUDIO_INPUT_DEVICES_CACHE: List[Dict[str, Any]] = []
class Offer(BaseModel):
sdp: str
type: str
def get_device_index_by_name(name: str):
"""Return the device index for a given device name, or None if not found."""
for d in AUDIO_INPUT_DEVICES_CACHE:
if d["name"] == name:
return d["id"]
return None
# Path to persist stream settings
STREAM_SETTINGS_FILE = os.path.join(os.path.dirname(__file__), 'stream_settings.json')
def load_stream_settings() -> dict:
"""Load persisted stream settings if available."""
if os.path.exists(STREAM_SETTINGS_FILE):
try:
with open(STREAM_SETTINGS_FILE, 'r', encoding='utf-8') as f:
return json.load(f)
except Exception:
return {}
return {}
def save_stream_settings(settings: dict):
"""Save stream settings to disk."""
try:
with open(STREAM_SETTINGS_FILE, 'w', encoding='utf-8') as f:
json.dump(settings, f, indent=2)
except Exception as e:
log.error('Unable to persist stream settings: %s', e)
app = FastAPI()
# Allow CORS for frontend on localhost
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # You can restrict this to ["http://localhost:8501"] if you want
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Initialize global configuration
global_config_group = auracast_config.AuracastConfigGroup()
# Create multicast controller
multicaster1: multicast_control.Multicaster | None = None
multicaster2: multicast_control.Multicaster | None = None
@app.post("/init")
async def initialize(conf: auracast_config.AuracastConfigGroup):
"""Initializes the primary broadcaster (multicaster1)."""
global global_config_group
global multicaster1
try:
if conf.transport == 'auto':
serial_devices = glob.glob('/dev/serial/by-id/*')
log.info('Found serial devices: %s', serial_devices)
for device in serial_devices:
if 'usb-ZEPHYR_Zephyr_HCI_UART_sample' in device:
log.info('Using: %s', device)
conf.transport = f'serial:{device},115200,rtscts'
break
if conf.transport == 'auto':
raise HTTPException(status_code=500, detail='No suitable transport found.')
# Derive audio_mode and input_device from first BIG audio_source
first_source = conf.bigs[0].audio_source if conf.bigs else ''
if first_source.startswith('device:'):
audio_mode_persist = 'USB'
input_device_name = first_source.split(':', 1)[1] if ':' in first_source else None
# Map device name to current index for use with sounddevice
device_index = get_device_index_by_name(input_device_name) if input_device_name else None
# Patch config to use index for sounddevice (but persist name)
if device_index is not None:
for big in conf.bigs:
if big.audio_source.startswith('device:'):
big.audio_source = f'device:{device_index}'
else:
log.error(f"Device name '{input_device_name}' not found in current device list.")
raise HTTPException(status_code=400, detail=f"Audio device '{input_device_name}' not found.")
elif first_source == 'webrtc':
audio_mode_persist = 'Webapp'
input_device_name = None
elif first_source.startswith('file:'):
audio_mode_persist = 'Demo'
input_device_name = None
else:
audio_mode_persist = 'Network'
input_device_name = None
save_stream_settings({
'channel_names': [big.name for big in conf.bigs],
'languages': [big.language for big in conf.bigs],
'audio_mode': audio_mode_persist,
'input_device': input_device_name,
'program_info': [getattr(big, 'program_info', None) for big in conf.bigs],
'gain': [getattr(big, 'input_gain', 1.0) for big in conf.bigs],
'timestamp': datetime.utcnow().isoformat()
})
global_config_group = conf
if multicaster1 is not None:
try:
await multicaster1.shutdown()
except Exception:
log.warning("Failed to shutdown previous multicaster", exc_info=True)
log.info('Initializing multicaster1 with config:\n %s', conf.model_dump_json(indent=2))
multicaster1 = multicast_control.Multicaster(conf, conf.bigs)
await multicaster1.init_broadcast()
if any(big.audio_source.startswith("device:") or big.audio_source.startswith("file:") for big in conf.bigs):
log.info("Auto-starting streaming on multicaster1")
await multicaster1.start_streaming()
except Exception as e:
log.error("Exception in /init: %s", traceback.format_exc())
raise HTTPException(status_code=500, detail=str(e))
@app.post("/init2")
async def initialize2(conf: auracast_config.AuracastConfigGroup):
"""Initializes the secondary broadcaster (multicaster2). Does NOT persist stream settings."""
global multicaster2
try:
if conf.transport == 'auto':
serial_devices = glob.glob('/dev/serial/by-id/*')
log.info('Found serial devices: %s', serial_devices)
for device in serial_devices:
if 'usb-ZEPHYR_Zephyr_HCI_UART_sample' in device:
log.info('Using: %s', device)
conf.transport = f'serial:{device},115200,rtscts'
break
if conf.transport == 'auto':
raise HTTPException(status_code=500, detail='No suitable transport found.')
# Patch device name to index for sounddevice
for big in conf.bigs:
if big.audio_source.startswith('device:'):
device_name = big.audio_source.split(':', 1)[1]
device_index = get_device_index_by_name(device_name)
if device_index is not None:
big.audio_source = f'device:{device_index}'
else:
log.error(f"Device name '{device_name}' not found in current device list.")
raise HTTPException(status_code=400, detail=f"Audio device '{device_name}' not found.")
log.info('Initializing multicaster2 with config:\n %s', conf.model_dump_json(indent=2))
multicaster2 = multicast_control.Multicaster(conf, conf.bigs)
await multicaster2.init_broadcast()
if any(big.audio_source.startswith("device:") or big.audio_source.startswith("file:") for big in conf.bigs):
log.info("Auto-starting streaming on multicaster2")
await multicaster2.start_streaming()
except Exception as e:
log.error("Exception in /init2: %s", traceback.format_exc())
raise HTTPException(status_code=500, detail=str(e))
@app.post("/stream_lc3")
async def send_audio(audio_data: dict[str, str]):
"""Sends a block of pre-coded LC3 audio."""
if multicaster1 is None:
raise HTTPException(status_code=500, detail='Auracast endpoint was never intialized')
try:
for big in global_config_group.bigs:
assert big.language in audio_data, HTTPException(status_code=500, detail='language len missmatch')
log.info('Received a send audio request for %s', big.language)
big.audio_source = audio_data[big.language].encode('latin-1') # TODO: use base64 encoding
multicaster1.big_conf = global_config_group.bigs
await multicaster1.start_streaming()
return {"status": "audio_sent"}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/stop_audio")
async def stop_audio():
"""Stops streaming on both multicaster1 and multicaster2."""
try:
# First close any active WebRTC peer connections so their track loops finish cleanly
close_tasks = [pc.close() for pc in list(pcs)]
pcs.clear()
if close_tasks:
await asyncio.gather(*close_tasks, return_exceptions=True)
# Now shut down both multicasters and release audio devices
running = False
if multicaster1 is not None:
await multicaster1.stop_streaming()
await multicaster1.reset() # Fully reset controller and advertising
running = True
if multicaster2 is not None:
await multicaster2.stop_streaming()
await multicaster2.reset() # Fully reset controller and advertising
running = True
return {"status": "stopped", "was_running": running}
except Exception as e:
log.error("Exception in /stop_audio: %s", traceback.format_exc())
raise HTTPException(status_code=500, detail=str(e))
@app.get("/status")
async def get_status():
"""Gets the current status of the multicaster together with persisted stream info."""
status = multicaster1.get_status() if multicaster1 else {
'is_initialized': False,
'is_streaming': False,
}
status.update(load_stream_settings())
return status
async def scan_audio_devices():
"""Scans for available audio devices and updates the cache."""
global AUDIO_INPUT_DEVICES_CACHE
log.info("Scanning for audio input devices...")
try:
if sys.platform == 'linux':
log.info("Re-initializing sounddevice to scan for new devices")
sd._terminate()
sd._initialize()
devs = sd.query_devices()
inputs = [
dict(d, id=idx)
for idx, d in enumerate(devs)
if d.get("max_input_channels", 0) > 0
]
log.info('Found %d audio input devices: %s', len(inputs), inputs)
AUDIO_INPUT_DEVICES_CACHE = inputs
except Exception:
log.error("Exception while scanning audio devices:", exc_info=True)
# Do not clear cache on error, keep the last known good list
@app.on_event("startup")
async def startup_event():
"""Pre-scans audio devices on startup."""
await scan_audio_devices()
@app.get("/audio_inputs")
async def list_audio_inputs():
"""Return available hardware audio input devices from cache (by name, for selection)."""
# Only expose name and id for frontend
return {"inputs": AUDIO_INPUT_DEVICES_CACHE}
@app.post("/refresh_audio_inputs")
async def refresh_audio_inputs():
"""Triggers a re-scan of audio devices."""
await scan_audio_devices()
return {"status": "ok", "inputs": AUDIO_INPUT_DEVICES_CACHE}
@app.post("/offer")
async def offer(offer: Offer):
log.info("/offer endpoint called")
# If a previous PeerConnection is still alive, close it so we only ever keep one active.
if pcs:
log.info("Closing %d existing PeerConnection(s) before creating a new one", len(pcs))
close_tasks = [p.close() for p in list(pcs)]
await asyncio.gather(*close_tasks, return_exceptions=True)
pcs.clear()
pc = RTCPeerConnection() # No STUN needed for localhost
pcs.add(pc)
id_ = uuid.uuid4().hex[:8]
log.info(f"{id_}: new PeerConnection")
# create directory for records - only for testing
os.makedirs("./records", exist_ok=True)
# Do NOT start the streamer yet we'll start it lazily once we actually
# receive the first audio frame, ensuring WebRTCAudioInput is ready and
# avoiding race-conditions on restarts.
@pc.on("track")
async def on_track(track: MediaStreamTrack):
log.info(f"{id_}: track {track.kind} received")
try:
first = True
while True:
frame: av.audio.frame.AudioFrame = await track.recv() # RTP audio frame (already decrypted)
if first:
log.info(f"{id_}: frame layout={frame.layout}")
log.info(f"{id_}: frame format={frame.format}")
log.info(
f"{id_}: frame sample_rate={frame.sample_rate}, samples_per_channel={frame.samples}, planes={frame.planes}"
)
# Lazily start the streamer now that we know a track exists.
if multicaster1.streamer is None:
await multicaster1.start_streaming()
# Yield control so the Streamer coroutine has a chance to
# create the WebRTCAudioInput before we push samples.
await asyncio.sleep(0)
first = False
# in stereo case this is interleaved data format
frame_array = frame.to_ndarray()
log.info(f"array.shape{frame_array.shape}")
log.info(f"array.dtype{frame_array.dtype}")
log.info(f"frame.to_ndarray(){frame_array}")
samples = frame_array.reshape(-1)
log.info(f"samples.shape: {samples.shape}")
if frame.layout.name == 'stereo':
# Interleaved stereo: [L0, R0, L1, R1, ...]
mono_array = samples[::2] # Take left channel
else:
mono_array = samples
log.info(f"mono_array.shape: {mono_array.shape}")
frame_array = frame.to_ndarray()
# Flatten in case it's (1, N) or (N,)
samples = frame_array.reshape(-1)
if frame.layout.name == 'stereo':
# Interleaved stereo: [L0, R0, L1, R1, ...]
mono_array = samples[::2] # Take left channel
else:
mono_array = samples
# Get current WebRTC audio input (streamer may have been restarted)
big0 = list(multicaster1.bigs.values())[0]
audio_input = big0.get('audio_input')
# Wait until the streamer has instantiated the WebRTCAudioInput
if audio_input is None or getattr(audio_input, 'closed', False):
continue
# Feed mono PCM samples to the global WebRTC audio input
await audio_input.put_samples(mono_array.astype(np.int16))
# Save to WAV file - only for testing
# if not hasattr(pc, 'wav_writer'):
# import wave
# wav_path = f"./records/auracast_{id_}.wav"
# pc.wav_writer = wave.open(wav_path, "wb")
# pc.wav_writer.setnchannels(1) # mono
# pc.wav_writer.setsampwidth(2) # 16-bit PCM
# pc.wav_writer.setframerate(frame.sample_rate)
# pcm_data = mono_array.astype(np.int16).tobytes()
# pc.wav_writer.writeframes(pcm_data)
except Exception as e:
log.error(f"{id_}: Exception in on_track: {e}")
finally:
# Always close the wav file when the track ends or on error
if hasattr(pc, 'wav_writer'):
try:
pc.wav_writer.close()
except Exception:
pass
del pc.wav_writer
# --- SDP negotiation ---
log.info(f"{id_}: setting remote description")
await pc.setRemoteDescription(RTCSessionDescription(**offer.model_dump()))
log.info(f"{id_}: creating answer")
answer = await pc.createAnswer()
sdp = answer.sdp
# Insert a=ptime using the global PTIME variable
ptime_line = f"a=ptime:{PTIME}"
if "a=sendrecv" in sdp:
sdp = sdp.replace("a=sendrecv", f"a=sendrecv\n{ptime_line}")
else:
sdp += f"\n{ptime_line}"
new_answer = RTCSessionDescription(sdp=sdp, type=answer.type)
await pc.setLocalDescription(new_answer)
log.info(f"{id_}: sending answer with {ptime_line}")
return {"sdp": pc.localDescription.sdp,
"type": pc.localDescription.type}
@app.post("/shutdown")
async def shutdown():
"""Stops broadcasting and releases all audio/Bluetooth resources."""
try:
await multicaster1.shutdown()
return {"status": "stopped"}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
if __name__ == '__main__':
import os
os.chdir(os.path.dirname(__file__))
import uvicorn
log.basicConfig( # for debug log level export LOG_LEVEL=DEBUG
level=os.environ.get('LOG_LEVEL', log.INFO),
format='%(module)s.py:%(lineno)d %(levelname)s: %(message)s'
)
# Bind to localhost only for security: prevents network access, only frontend on same machine can connect
uvicorn.run(app, host="127.0.0.1", port=5000)

View File

@@ -0,0 +1,89 @@
#!/bin/bash
# change_domain_hostname.sh
# Safely change the system hostname and Avahi mDNS domain name, update /etc/hosts, restart Avahi,
# and generate a per-device certificate signed by the CA.
# Usage: sudo ./change_domain_hostname.sh <new_hostname> <new_domain> [--force]
set -e
if [ "$EUID" -ne 0 ]; then
echo "Please run as root."
exit 1
fi
if [ $# -lt 2 ]; then
echo "Usage: sudo $0 <new_hostname> <new_domain> [--force]"
exit 1
fi
NEW_HOSTNAME="$1"
NEW_DOMAIN="$2"
FORCE=0
if [ "$3" == "--force" ]; then
FORCE=1
fi
# Validate hostname: single label, no dots
if [[ "$NEW_HOSTNAME" == *.* ]]; then
echo "ERROR: Hostname must not contain dots."
exit 1
fi
# Set system hostname
hostnamectl set-hostname "$NEW_HOSTNAME"
echo "/etc/hostname set to $NEW_HOSTNAME."
# Update /etc/hosts
if grep -q '^127.0.1.1' /etc/hosts; then
sed -i "s/^127.0.1.1.*/127.0.1.1 $NEW_HOSTNAME/" /etc/hosts
else
echo "127.0.1.1 $NEW_HOSTNAME" >> /etc/hosts
fi
echo "/etc/hosts updated."
# Set Avahi domain name
AVAHI_CONF="/etc/avahi/avahi-daemon.conf"
sed -i "/^\[server\]/,/^\s*\[/{s/^\s*domain-name\s*=.*/domain-name=$NEW_DOMAIN/}" "$AVAHI_CONF"
echo "Set Avahi domain name to $NEW_DOMAIN."
# Restart Avahi
echo "Restarting avahi-daemon..."
systemctl restart avahi-daemon
echo "Done. Hostname: $NEW_HOSTNAME, Avahi domain: $NEW_DOMAIN"
# --- Per-device certificate logic ---
CA_DIR="$(dirname "$0")/certs/ca"
PER_DEVICE_DIR="$(dirname "$0")/certs/per_device/$NEW_HOSTNAME.$NEW_DOMAIN"
mkdir -p "$PER_DEVICE_DIR"
CA_CERT="$CA_DIR/ca_cert.pem"
CA_KEY="$CA_DIR/ca_key.pem"
DEVICE_CERT="$PER_DEVICE_DIR/device_cert.pem"
DEVICE_KEY="$PER_DEVICE_DIR/device_key.pem"
DEVICE_CSR="$PER_DEVICE_DIR/device.csr"
SAN_CNF="$PER_DEVICE_DIR/san.cnf"
if [ -f "$DEVICE_CERT" ] && [ $FORCE -eq 0 ]; then
echo "Per-device certificate already exists at $DEVICE_CERT. Use --force to regenerate."
else
echo "Generating per-device key/cert for $NEW_HOSTNAME.$NEW_DOMAIN..."
openssl genrsa -out "$DEVICE_KEY" 4096
cat > "$SAN_CNF" <<EOF
[req]
distinguished_name = req_distinguished_name
req_extensions = v3_req
prompt = no
[req_distinguished_name]
CN = $NEW_HOSTNAME.$NEW_DOMAIN
[v3_req]
subjectAltName = @alt_names
[alt_names]
DNS.1 = $NEW_HOSTNAME.$NEW_DOMAIN
EOF
openssl req -new -key "$DEVICE_KEY" -out "$DEVICE_CSR" -config "$SAN_CNF"
openssl x509 -req -in "$DEVICE_CSR" -CA "$CA_CERT" -CAkey "$CA_KEY" -CAcreateserial -out "$DEVICE_CERT" -days 7300 -extensions v3_req -extfile "$SAN_CNF"
echo "Per-device certificate generated at $DEVICE_CERT."
fi

View File

@@ -0,0 +1,2 @@
# Start Streamlit HTTP server (port 8500)
poetry run streamlit run multicast_frontend.py --server.port 8500 --server.enableCORS false --server.enableXsrfProtection false --server.headless true --browser.gatherUsageStats false

View File

@@ -0,0 +1,37 @@
#!/bin/bash
# to bind this to 443, root privileges are required. start this like
# sudo -E PATH="$PATH" bash ./start_frontend_https.sh
# Unified startup script: generates certs if needed, starts HTTPS Streamlit and HTTP->HTTPS redirector
# Dynamically select per-device cert and key based on hostname and Avahi domain
DEVICE_HOSTNAME=$(hostname)
AVAHI_CONF="/etc/avahi/avahi-daemon.conf"
AVAHI_DOMAIN=$(awk -F= '/^\s*domain-name\s*=/{gsub(/ /, "", $2); print $2}' "$AVAHI_CONF")
if [ -z "$AVAHI_DOMAIN" ]; then
AVAHI_DOMAIN=local
fi
CERT_DIR="certs/per_device/${DEVICE_HOSTNAME}.${AVAHI_DOMAIN}"
CERT="$CERT_DIR/device_cert.pem"
KEY="$CERT_DIR/device_key.pem"
CA_CERT="certs/ca/ca_cert.pem"
if [ ! -f "$CERT" ] || [ ! -f "$KEY" ]; then
echo "ERROR: Device certificate or key not found in $CERT_DIR. Run provision_domain_hostname.sh first."
exit 1
fi
if [ ! -f "$CA_CERT" ]; then
echo "WARNING: CA certificate not found at $CA_CERT. HTTPS will work, but clients may not be able to import the CA."
fi
echo "CA cert: $CA_CERT"
echo "Device cert: $CERT"
echo "Device key: $KEY"
echo "Using hostname: $DEVICE_HOSTNAME"
echo "Using Avahi domain: $AVAHI_DOMAIN"
# Path to poetry binary
POETRY_BIN="/home/caster/.local/bin/poetry"
# Start Streamlit HTTPS server (port 443)
$POETRY_BIN run streamlit run multicast_frontend.py --server.port 443 --server.enableCORS false --server.enableXsrfProtection false --server.headless true --server.sslCertFile "$CERT" --server.sslKeyFile "$KEY" --browser.gatherUsageStats false

View File

@@ -0,0 +1,26 @@
#!/bin/bash
# Script to advertise the local device via mDNS for an HTTPS service.
# This allows other clients on the network to discover this device
# using its mDNS hostname (e.g., your-hostname.local) on the specified port.
# Update: Advertise HTTPS service on port 443 (default)
SERVICE_NAME="Auracast HTTPS Service" # You can customize this name
SERVICE_TYPE="_https._tcp" # Standard type for HTTPS services
SERVICE_PORT="443" # Port must match your HTTPS server (default 443)
echo "Starting mDNS advertisement..."
echo "Command: avahi-publish-service -v \"$SERVICE_NAME\" \"$SERVICE_TYPE\" \"$SERVICE_PORT\""
avahi-publish-service -v "$SERVICE_NAME" "$SERVICE_TYPE" "$SERVICE_PORT"
EXIT_STATUS=$?
# This part will be reached if avahi-publish-service exits.
if [ $EXIT_STATUS -eq 0 ]; then
echo "mDNS advertisement command finished with status 0."
echo "This might indicate an issue connecting to the avahi-daemon or a configuration problem."
echo "Please check for any messages above from avahi-publish-service itself."
else
echo "mDNS advertisement command exited with status $EXIT_STATUS."
echo "This might be due to an error, or if you pressed Ctrl+C (which typically results in a non-zero status from signal termination)."
fi

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,67 @@
import asyncio
import socket
import logging
import numpy as np
from typing import AsyncGenerator
class NetworkAudioReceiverUncoded:
"""
Receives PCM audio over UDP and provides an async generator interface for uncoded PCM frames.
Combines network receiving and input logic for use with Auracast streamer.
"""
def __init__(self, port: int = 50007, samplerate: int = 16000, channels: int = 1, chunk_size: int = 1024):
self.port = port
self.samplerate = samplerate
self.channels = channels
self.chunk_size = chunk_size
self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
self.sock.bind(('0.0.0.0', self.port))
self.sock.setblocking(False)
self._running = False
# Reduce queue size for lower latency (less buffering)
self._queue = asyncio.Queue(maxsize=2) # Was 20
async def receive(self):
self._running = True
logging.info(f"NetworkAudioReceiver listening on UDP port {self.port}")
try:
while self._running:
try:
data, _ = await asyncio.get_event_loop().sock_recvfrom(self.sock, self.chunk_size * 2)
await self._queue.put(data)
except Exception:
await asyncio.sleep(0.01)
finally:
self.sock.close()
logging.info("NetworkAudioReceiver stopped.")
def stop(self):
self._running = False
async def open(self):
# Dummy PCM format object
class PCMFormat:
channels = self.channels
sample_type = 'int16'
sample_rate = self.samplerate
return PCMFormat()
def rewind(self):
pass # Not supported for live network input
async def frames(self, samples_per_frame: int) -> AsyncGenerator[np.ndarray, None]:
bytes_per_frame = samples_per_frame * 2 * self.channels # 2 bytes for int16
buf = bytearray()
while True:
data = await self._queue.get()
# Optional: log queue size for latency debugging
# logging.debug(f'NetworkAudioReceiver queue size: {self._queue.qsize()}')
if data is None:
break
buf.extend(data)
while len(buf) >= bytes_per_frame:
frame = np.frombuffer(buf[:bytes_per_frame], dtype=np.int16).reshape(-1, self.channels)
# Optional: log when a frame is yielded
# logging.debug(f'Yielding frame of shape {frame.shape}')
yield frame
buf = buf[bytes_per_frame:]

View File

@@ -0,0 +1,141 @@
import sounddevice as sd
import os, re, json, subprocess
def devices_by_backend(backend_name: str):
hostapis = sd.query_hostapis() # list of host APIs
# find the host API index by (case-insensitive) name match
try:
hostapi_idx = next(
i for i, ha in enumerate(hostapis)
if backend_name.lower() in ha['name'].lower()
)
except StopIteration:
raise ValueError(f"No host API matching {backend_name!r}. "
f"Available: {[ha['name'] for ha in hostapis]}")
# return (global_index, device_dict) pairs filtered by that host API
return [(i, d) for i, d in enumerate(sd.query_devices())
if d['hostapi'] == hostapi_idx]
def _pa_like_hostapi_index():
for i, ha in enumerate(sd.query_hostapis()):
if any(k in ha["name"] for k in ("PipeWire", "PulseAudio")):
return i
raise RuntimeError("PipeWire/PulseAudio host API not present in PortAudio.")
def _pw_dump():
return json.loads(subprocess.check_output(["pw-dump"]))
def _sd_refresh():
"""Force PortAudio to re-enumerate devices on next query.
sounddevice/PortAudio keeps a static device list after initialization.
Terminating here ensures that subsequent sd.query_* calls re-initialize
and see newly added devices (e.g., AES67 nodes created after start).
"""
sd._terminate() # private API, acceptable for runtime refresh
sd._initialize()
def _sd_matches_from_names(pa_idx, names):
names_l = {n.lower() for n in names if n}
out = []
for i, d in enumerate(sd.query_devices()):
if d["hostapi"] != pa_idx or d["max_input_channels"] <= 0:
continue
dn = d["name"].lower()
if any(n in dn for n in names_l):
out.append((i, d))
return out
def list_usb_pw_inputs():
"""
Return [(device_index, device_dict), ...] for PipeWire **input** nodes
backed by **USB** devices (excludes monitor sources).
"""
# Refresh PortAudio so we see newly added nodes before mapping
_sd_refresh()
pa_idx = _pa_like_hostapi_index()
pw = _pw_dump()
# Map device.id -> device.bus ("usb"/"pci"/"platform"/"network"/...)
device_bus = {}
for obj in pw:
if obj.get("type") == "PipeWire:Interface:Device":
props = (obj.get("info") or {}).get("props") or {}
device_bus[obj["id"]] = (props.get("device.bus") or "").lower()
# Collect names/descriptions of USB input nodes
usb_input_names = set()
for obj in pw:
if obj.get("type") != "PipeWire:Interface:Node":
continue
props = (obj.get("info") or {}).get("props") or {}
media = (props.get("media.class") or "").lower()
if "source" not in media and "stream/input" not in media:
continue
# skip monitor sources ("Monitor of ..." or *.monitor)
nname = (props.get("node.name") or "").lower()
ndesc = (props.get("node.description") or "").lower()
if ".monitor" in nname or "monitor" in ndesc:
continue
bus = (props.get("device.bus") or device_bus.get(props.get("device.id")) or "").lower()
if bus == "usb":
usb_input_names.add(props.get("node.description") or props.get("node.name"))
# Map to sounddevice devices on PipeWire host API
return _sd_matches_from_names(pa_idx, usb_input_names)
def list_network_pw_inputs():
"""
Return [(device_index, device_dict), ...] for PipeWire **input** nodes that
look like network/AES67/RTP sources (excludes monitor sources).
"""
# Refresh PortAudio so we see newly added nodes before mapping
_sd_refresh()
pa_idx = _pa_like_hostapi_index()
pw = _pw_dump()
network_input_names = set()
for obj in pw:
if obj.get("type") != "PipeWire:Interface:Node":
continue
props = (obj.get("info") or {}).get("props") or {}
media = (props.get("media.class") or "").lower()
if "source" not in media and "stream/input" not in media:
continue
nname = (props.get("node.name") or "")
ndesc = (props.get("node.description") or "")
# skip monitor sources
if ".monitor" in nname.lower() or "monitor" in ndesc.lower():
continue
# Heuristics for network/AES67/RTP
text = (nname + " " + ndesc).lower()
media_name = (props.get("media.name") or "").lower()
node_group = (props.get("node.group") or "").lower()
# Presence flags/keys that strongly indicate network RTP/AES67 sources
node_network_flag = bool(props.get("node.network"))
has_rtp_keys = any(k in props for k in (
"rtp.session", "rtp.source.ip", "rtp.source.port", "rtp.fmtp", "rtp.rate"
))
has_sess_keys = any(k in props for k in (
"sess.name", "sess.media", "sess.latency.msec"
))
is_network = (
(props.get("device.bus") or "").lower() == "network" or
node_network_flag or
"rtp" in media_name or
any(k in text for k in ("rtp", "sap", "aes67", "network", "raop", "airplay")) or
has_rtp_keys or
has_sess_keys or
("pipewire.ptp" in node_group)
)
if is_network:
network_input_names.add(ndesc or nname)
return _sd_matches_from_names(pa_idx, network_input_names)
# Example usage:
# for i, d in list_usb_pw_inputs():
# print(f"USB IN {i}: {d['name']} in={d['max_input_channels']}")
# for i, d in list_network_pw_inputs():
# print(f"NET IN {i}: {d['name']} in={d['max_input_channels']}")

View File

@@ -0,0 +1,42 @@
import asyncio
import numpy as np
import logging
class WebRTCAudioInput:
"""
Buffer PCM samples from WebRTC and provide an async generator interface for chunked frames.
"""
def __init__(self):
self.buffer = np.array([], dtype=np.int16)
self.lock = asyncio.Lock()
self.data_available = asyncio.Event()
self.closed = False
async def frames(self, frame_size: int):
"""
Async generator yielding exactly frame_size samples as numpy arrays.
"""
while not self.closed:
async with self.lock:
if len(self.buffer) >= frame_size:
chunk = self.buffer[:frame_size]
self.buffer = self.buffer[frame_size:]
logging.debug(f"WebRTCAudioInput: Yielding {frame_size} samples, buffer now has {len(self.buffer)} samples remaining.")
yield chunk
else:
self.data_available.clear()
await self.data_available.wait()
async def put_samples(self, samples: np.ndarray):
"""
Add new PCM samples (1D np.int16 array, mono) to the buffer.
"""
async with self.lock:
self.buffer = np.concatenate([self.buffer, samples])
logging.debug(f"WebRTCAudioInput: Added {len(samples)} samples, buffer now has {len(self.buffer)} samples.")
self.data_available.set()
async def close(self):
"""Mark the input closed so frames() stops yielding."""
self.closed = True
self.data_available.set()

View File

@@ -0,0 +1,24 @@
import sounddevice as sd, pprint
from auracast.utils.sounddevice_utils import devices_by_backend, list_usb_pw_inputs, list_network_pw_inputs
print("PortAudio library:", sd._libname)
print("PortAudio version:", sd.get_portaudio_version())
print("\nHost APIs:")
pprint.pprint(sd.query_hostapis())
print("\nDevices:")
pprint.pprint(sd.query_devices())
# Example: only PulseAudio devices on Linux
print("\nOnly PulseAudio devices:")
for i, d in devices_by_backend("PulseAudio"):
print(f"{i}: {d['name']} in={d['max_input_channels']} out={d['max_output_channels']}")
print("Network pw inputs:")
for i, d in list_network_pw_inputs():
print(f"{i}: {d['name']} in={d['max_input_channels']}")
print("USB pw inputs:")
for i, d in list_usb_pw_inputs():
print(f"{i}: {d['name']} in={d['max_input_channels']}")

View File

@@ -0,0 +1,6 @@
# This file was installed by PipeWire project for its pipewire-aes67
#
# This is used to give readonly access to the PTP hardware clock.
# PipeWire uses this to follow PTP grandmaster time. It should be synced by another service
#
KERNEL=="ptp[0-9]*", MODE="0644"

View File

@@ -0,0 +1,114 @@
# AES67 config file for PipeWire version "1.2.7" #
#
# Copy and edit this file in /etc/pipewire for system-wide changes
# or in ~/.config/pipewire for local changes.
#
# It is also possible to place a file with an updated section in
# /etc/pipewire/pipewire-aes67.conf.d/ for system-wide changes or in
# ~/.config/pipewire/pipewire-aes67.conf.d/ for local changes.
#
context.properties = {
## Configure properties in the system.
default.clock.rate = 48000
default.clock.allowed-rates = [ 48000 ]
# Enforce 3ms quantum on this AES67 PipeWire instance
clock.force-quantum = 144
default.clock.quantum = 144
#mem.warn-mlock = false
#mem.allow-mlock = true
#mem.mlock-all = false
#log.level = 2
#default.clock.quantum-limit = 8192
}
context.spa-libs = {
support.* = support/libspa-support
}
context.objects = [
# An example clock reading from /dev/ptp0. You can also specify the network interface name,
# pipewire will query the interface for the current active PHC index. Another option is to
# sync the ptp clock to CLOCK_TAI and then set clock.id = tai, keep in mind that tai may
# also be synced by a NTP client.
# The precedence is: device, interface, id
{ factory = spa-node-factory
args = {
factory.name = support.node.driver
node.name = PTP0-Driver
node.group = pipewire.ptp0
# This driver should only be used for network nodes marked with group
priority.driver = 100000
clock.name = "clock.system.ptp0"
### Please select the PTP hardware clock here
# Interface name is the preferred method of specifying the PHC
clock.interface = "eth0"
#clock.device = "/dev/ptp0"
#clock.id = tai
# Lower this in case of periodic out-of-sync
resync.ms = 1.5
object.export = true
}
}
]
context.modules = [
{ name = libpipewire-module-rt
args = {
nice.level = -11
#rt.prio = 83
#rt.time.soft = -1
#rt.time.hard = -1
}
flags = [ ifexists nofail ]
}
{ name = libpipewire-module-protocol-native }
{ name = libpipewire-module-client-node }
{ name = libpipewire-module-spa-node-factory }
{ name = libpipewire-module-adapter }
{ name = libpipewire-module-rtp-sap
args = {
### Please select the interface here
local.ifname = eth0
sap.ip = 239.255.255.255
sap.port = 9875
net.ttl = 32
net.loop = false
# If you use another PTPv2 daemon supporting management
# messages over a UNIX socket, specify its path here
ptp.management-socket = "/var/run/ptp4lro"
stream.rules = [
{
matches = [
{
rtp.session = "~.*"
}
]
actions = {
create-stream = {
node.virtual = false
media.class = "Audio/Source"
device.api = aes67
# You can adjust the latency buffering here. Use integer values only
sess.latency.msec = 6
node.latency = "144/48000"
node.group = pipewire.ptp0
}
}
},
{
matches = [
{
sess.sap.announce = true
}
]
actions = {
announce-stream = {}
}
}
]
}
},
]

View File

@@ -0,0 +1,19 @@
[global]
priority1 255
priority2 254
# Lower = more likely to become Grandmaster. Keep the same on both for "either can be master".
domainNumber 0
# Default domain
logSyncInterval -3
# AES67 profile: Sync messages every 125ms
logAnnounceInterval 1
# Announce messages every 2s (AES67 default)
logMinDelayReqInterval 0
dscp_event 46
# QoS for event messages
dscp_general 0
# QoS for general messages
step_threshold 1
# Fast convergence on time jumps
tx_timestamp_timeout 20

View File

@@ -0,0 +1,15 @@
[Unit]
Description=Auracast Frontend HTTPS Server
# Ensure backend is running as a user service before starting frontend
After=auracast-server.service network.target
Wants=auracast-server.service
[Service]
Type=simple
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
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,14 @@
[Unit]
Description=Auracast Multicast Script
After=network.target
[Service]
Type=simple
WorkingDirectory=/home/caster/bumble-auracast
ExecStart=/home/caster/.local/bin/poetry run python src/auracast/multicast_script.py
Restart=on-failure
Environment=PYTHONUNBUFFERED=1
Environment=LOG_LEVEL=INFO
[Install]
WantedBy=default.target

View File

@@ -0,0 +1,14 @@
[Unit]
Description=Auracast Backend Server
After=network.target
[Service]
Type=simple
WorkingDirectory=/home/caster/bumble-auracast
ExecStart=/home/caster/.local/bin/poetry run python src/auracast/server/multicast_server.py
Restart=on-failure
Environment=PYTHONUNBUFFERED=1
Environment=LOG_LEVEL=INFO
[Install]
WantedBy=default.target

View File

@@ -0,0 +1,11 @@
[Unit]
Description=PipeWire AES67 Service
After=network.target
[Service]
Type=simple
ExecStart=/usr/bin/pipewire-aes67 -c /home/caster/bumble-auracast/src/service/aes67/pipewire-aes67.conf
Restart=on-failure
[Install]
WantedBy=default.target

View File

@@ -0,0 +1,13 @@
context.properties = {
default.clock.rate = 48000
default.clock.allowed-rates = [ 48000 ]
default.clock.quantum = 144 # 144/48000 = 3.0 ms
default.clock.min-quantum = 32
default.clock.max-quantum = 256
}
stream.properties = {
# Prefer to let specific nodes (e.g. AES67) or clients set node.latency.
node.latency = "144/48000"
resample.quality = 0
}

View File

@@ -0,0 +1,13 @@
[Unit]
Description=PTP AES67 Service
After=network.target
[Service]
Type=simple
ExecStart=/usr/sbin/ptp4l -i eth0 -f /home/caster/bumble-auracast/src/service/aes67/ptp_aes67_1.conf
Restart=on-failure
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,84 @@
#!/bin/bash
set -euo pipefail
# Print status of relevant services and warn if mutually exclusive ones are running.
# Utilities (network audio):
# - ptp_aes67.service (system)
# - pipewire-aes67.service (user)
# App services (mutually exclusive groups):
# - auracast-script.service (user)
# - auracast-server.service (prefer user; also check system)
# - auracast-frontend.service (system)
print_status() {
local scope="$1" # "system" or "user"
local unit="$2"
local act="unknown"
local ena="unknown"
if [[ "$scope" == "user" ]]; then
act=$(systemctl --user is-active "$unit" 2>/dev/null || true)
ena=$(systemctl --user is-enabled "$unit" 2>/dev/null || true)
else
act=$(systemctl is-active "$unit" 2>/dev/null || true)
ena=$(systemctl is-enabled "$unit" 2>/dev/null || true)
fi
act="${act:-unknown}"
ena="${ena:-unknown}"
printf " - %-24s [%s] active=%-10s enabled=%s\n" "$unit" "$scope" "$act" "$ena"
}
is_active() {
local scope="$1" unit="$2"
if [[ "$scope" == "user" ]]; then
systemctl --user is-active "$unit" &>/dev/null && echo yes || echo no
else
systemctl is-active "$unit" &>/dev/null && echo yes || echo no
fi
}
hr() { printf '\n%s\n' "----------------------------------------"; }
printf "AURACAST SERVICE STATUS\n"
hr
printf "Utilities (required for network audio)\n"
print_status system ptp_aes67.service
print_status user pipewire-aes67.service
PTP_ACTIVE=$(is_active system ptp_aes67.service)
PW_ACTIVE=$(is_active user pipewire-aes67.service)
if [[ "$PTP_ACTIVE" == "yes" && "$PW_ACTIVE" == "yes" ]]; then
echo " ✓ Utilities ready for AES67/network audio"
else
echo " ! Utilities not fully active (AES67/network audio may not work)"
fi
hr
printf "Application services (mutually exclusive)\n"
print_status user auracast-script.service
print_status user auracast-server.service
print_status system auracast-server.service
print_status system auracast-frontend.service
SCRIPT_ACTIVE=$(is_active user auracast-script.service)
SERVER_USER_ACTIVE=$(is_active user auracast-server.service)
SERVER_SYS_ACTIVE=$(is_active system auracast-server.service)
FRONT_ACTIVE=$(is_active system auracast-frontend.service)
# Consider server active if either user or system instance is active
if [[ "$SERVER_USER_ACTIVE" == "yes" || "$SERVER_SYS_ACTIVE" == "yes" ]]; then
SERVER_ACTIVE=yes
else
SERVER_ACTIVE=no
fi
if [[ "$SCRIPT_ACTIVE" == "yes" && ( "$SERVER_ACTIVE" == "yes" || "$FRONT_ACTIVE" == "yes" ) ]]; then
echo " ! WARNING: 'auracast-script' and 'server/frontend' are running together (mutually exclusive)."
fi
hr
printf "Hints\n"
echo " - Follow logs (user): journalctl --user -u auracast-script.service -f"
echo " - Follow logs (server): journalctl --user -u auracast-server.service -f || journalctl -u auracast-server.service -f"
echo " - Frontend logs: journalctl -u auracast-frontend.service -f"

21
src/service/stop_aes67.sh Normal file
View File

@@ -0,0 +1,21 @@
#!/bin/bash
set -e
# This script stops and disables the AES67 services
# Requires sudo privileges
# Stop services
sudo systemctl stop ptp_aes67.service
systemctl --user stop pipewire-aes67.service
# Disable services from starting on boot
sudo systemctl disable ptp_aes67.service
systemctl --user disable pipewire-aes67.service
echo "\n--- ptp_aes67.service status ---"
sudo systemctl status ptp_aes67.service --no-pager
echo "\n--- pipewire-aes67.service status (user) ---"
systemctl --user status pipewire-aes67.service --no-pager
echo "AES67 services stopped, disabled, and status printed successfully."

View File

@@ -0,0 +1,15 @@
#!/bin/bash
set -e
# This script stops and disables the auracast-script user service
# Stop service
systemctl --user stop auracast-script.service || true
# Disable service from starting on login
systemctl --user disable auracast-script.service || true
echo "\n--- auracast-script.service status (user) ---"
systemctl --user status auracast-script.service --no-pager || true
echo "auracast-script service stopped, disabled, and status printed successfully."

View File

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

View File

@@ -0,0 +1,23 @@
#!/bin/bash
set -e
# This script installs, enables, and restarts the auracast-script user service
# No sudo is required as it is a user service
# Copy user service file for auracast-script
mkdir -p /home/caster/.config/systemd/user
cp /home/caster/bumble-auracast/src/service/auracast-script.service /home/caster/.config/systemd/user/auracast-script.service
# Reload systemd to recognize new/updated services
systemctl --user daemon-reload
# Enable service to start on user login
systemctl --user enable auracast-script.service
# Restart service
systemctl --user restart auracast-script.service
echo "\n--- auracast-script.service status (user) ---"
systemctl --user status auracast-script.service --no-pager
echo "auracast-script service updated, enabled, restarted, and status printed successfully."

View File

@@ -0,0 +1,41 @@
#!/bin/bash
set -e
# This script installs, enables, and restarts the AES67 services
# Requires sudo privileges
# Copy system service file for ptp_aes67
sudo cp /home/caster/bumble-auracast/src/service/ptp_aes67.service /etc/systemd/system/ptp_aes67.service
# Copy user service file for pipewire-aes67
mkdir -p /home/caster/.config/systemd/user
cp /home/caster/bumble-auracast/src/service/pipewire-aes67.service /home/caster/.config/systemd/user/pipewire-aes67.service
# Install PipeWire user config to persist 3ms@48kHz (default.clock.quantum=144)
mkdir -p /home/caster/.config/pipewire/pipewire.conf.d
cp /home/caster/bumble-auracast/src/service/pipewire/99-lowlatency.conf /home/caster/.config/pipewire/pipewire.conf.d/99-lowlatency.conf
# Reload systemd to recognize new/updated services
sudo systemctl daemon-reload
systemctl --user daemon-reload
# Enable services to start on boot
sudo systemctl enable ptp_aes67.service
systemctl --user enable pipewire-aes67.service
# Restart services
systemctl --user restart pipewire.service pipewire-pulse.service
sudo systemctl restart ptp_aes67.service
systemctl --user restart pipewire-aes67.service
echo "\n--- pipewire.service status (user) ---"
systemctl --user status pipewire.service --no-pager
echo "\n--- ptp_aes67.service status ---"
sudo systemctl status ptp_aes67.service --no-pager
echo "\n--- pipewire-aes67.service status (user) ---"
systemctl --user status pipewire-aes67.service --no-pager
echo "AES67 services updated, enabled, restarted, and status printed successfully."

View File

@@ -0,0 +1,33 @@
#!/bin/bash
set -e
# This script installs, enables, and restarts the auracast-server and auracast-frontend services
# Requires sudo privileges
# Copy system service file for frontend
sudo cp /home/caster/bumble-auracast/src/service/auracast-frontend.service /etc/systemd/system/auracast-frontend.service
# Copy user service file for backend (now using WantedBy=default.target)
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
sudo systemctl daemon-reload
# Reload user systemd for server
systemctl --user daemon-reload
# 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
sudo systemctl restart auracast-frontend.service
systemctl --user restart auracast-server.service
echo "\n--- auracast-frontend.service status ---"
sudo systemctl status auracast-frontend.service --no-pager
echo "\n--- auracast-server.service status---"
systemctl --user status auracast-server.service --no-pager
echo "auracast-server and auracast-frontend services updated, enabled, restarted, and status printed successfully."

View File

@@ -0,0 +1,44 @@
"""Utility to diagnose Bumble SoundDeviceAudioInput compatibility.
Run inside the project venv:
python -m tests.usb_audio_diag [rate]
It enumerates all PortAudio input devices and tries to open each with Bumble's
create_audio_input using the URI pattern `device:<index>` with an explicit input_format of `int16le,<rate>,1`.
"""
from __future__ import annotations
import asyncio
import sys
import sounddevice as sd # type: ignore
from bumble.audio import io as audio_io # type: ignore
RATE = int(sys.argv[1]) if len(sys.argv) > 1 else 48000
aSYNC = asyncio.run
async def try_device(index: int, rate: int = 48000) -> None:
input_uri = f"device:{index}"
try:
audio_input = await audio_io.create_audio_input(input_uri, f"int16le,{rate},1")
fmt = await audio_input.open()
print(f"\033[32m✔ {input_uri} -> {fmt.channels}ch @ {fmt.sample_rate}Hz\033[0m")
if hasattr(audio_input, "aclose"):
await audio_input.aclose()
except Exception as exc: # pylint: disable=broad-except
print(f"\033[31m✗ {input_uri}: {exc}\033[0m")
async def main() -> None:
print(f"Trying PortAudio input devices with rate {RATE} Hz\n")
for idx, dev in enumerate(sd.query_devices()):
if dev["max_input_channels"] > 0 and "(hw:" in dev["name"].lower():
name = dev["name"]
print(f"[{idx}] {name}")
await try_device(idx, RATE)
print()
if __name__ == "__main__":
aSYNC(main())