Merge branch 'main' of ssh://ssh.pstruebi.xyz:222/auracaster/bumble-auracast

This commit is contained in:
2025-10-14 13:46:06 +02:00
23 changed files with 1378 additions and 512 deletions

View File

@@ -39,6 +39,7 @@ class AuracastGlobalConfig(BaseModel):
# When true, include a zero-length LTV with type 0x09 in the subgroup metadata
# so receivers may render earlier than the presentation delay for lower latency.
immediate_rendering: bool = False
assisted_listening_stream: bool = False
# "Audio input. "
# "'device' -> use the host's default sound input device, "

View File

@@ -45,7 +45,7 @@ import bumble.device
import bumble.transport
import bumble.utils
import numpy as np # for audio down-mix
from bumble.device import Host, BIGInfoAdvertisement, AdvertisingChannelMap
from bumble.device import Host, AdvertisingChannelMap
from bumble.audio import io as audio_io
from auracast import auracast_config
@@ -101,6 +101,24 @@ class ModWaveAudioInput(audio_io.ThreadedAudioInput):
audio_io.WaveAudioInput = ModWaveAudioInput
def broadcast_code_bytes(broadcast_code: str) -> bytes:
"""
Convert a broadcast code string to a 16-byte value.
If `broadcast_code` is `0x` followed by 32 hex characters, it is interpreted as a
raw 16-byte raw broadcast code in big-endian byte order.
Otherwise, `broadcast_code` is converted to a 16-byte value as specified in
BLUETOOTH CORE SPECIFICATION Version 6.0 | Vol 3, Part C , section 3.2.6.3
"""
if broadcast_code.startswith("0x") and len(broadcast_code) == 34:
return bytes.fromhex(broadcast_code[2:])[::-1]
broadcast_code_utf8 = broadcast_code.encode("utf-8")
if len(broadcast_code_utf8) > 16:
raise ValueError("broadcast code must be <= 16 bytes in utf-8 encoding")
padding = bytes(16 - len(broadcast_code_utf8))
return broadcast_code_utf8 + padding
# -----------------------------------------------------------------------------
# Logging
# -----------------------------------------------------------------------------
@@ -170,7 +188,15 @@ async def init_broadcast(
# Broadcast Audio Immediate Rendering flag (type 0x09), zero-length value
le_audio.Metadata.Entry(tag = le_audio.Metadata.Tag.BROADCAST_AUDIO_IMMEDIATE_RENDERING_FLAG, data=b"")
]
if global_config.immediate_rendering #TODO: verify this
if global_config.immediate_rendering
else []
)
+ (
[
# Assisted Listening Stream tag expects a 1-octet value. Use 0x01 to indicate enabled.
le_audio.Metadata.Entry(tag = le_audio.Metadata.Tag.ASSISTED_LISTENING_STREAM, data=b"\x01")
]
if global_config.assisted_listening_stream
else []
)
)
@@ -269,7 +295,7 @@ async def init_broadcast(
max_transport_latency=global_config.qos_config.max_transport_latency_ms,
rtn=global_config.qos_config.number_of_retransmissions,
broadcast_code=(
bytes.fromhex(conf.code) if conf.code else None
broadcast_code_bytes(conf.code) if conf.code else None
),
framing=frame_enable # needed if iso interval is not frame interval of codedc
),
@@ -287,8 +313,8 @@ async def init_broadcast(
bigs[f'big{i}']['iso_queue'] = iso_queue
logging.debug(f'big{i} parameters are:')
logging.debug('%s', pprint.pformat(vars(big)))
logging.info(f'big{i} parameters are:')
logging.info('%s', pprint.pformat(vars(big)))
logging.info(f'Finished setup of big{i}.')
await asyncio.sleep(i+1) # Wait for advertising to set up
@@ -349,18 +375,51 @@ class Streamer():
if self.task is not None:
self.task.cancel()
# Let cancellation propagate to the stream() coroutine
await asyncio.sleep(0.01)
self.task = None
# Close audio inputs (await to ensure ALSA devices are released)
close_tasks = []
async_closers = []
sync_closers = []
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)
if not ai:
continue
# First close any frames generator backed by the input to stop reads
frames_gen = big.get("frames_gen")
if frames_gen and hasattr(frames_gen, "aclose"):
try:
await frames_gen.aclose()
except Exception:
pass
big.pop("frames_gen", None)
if hasattr(ai, "aclose") and callable(getattr(ai, "aclose")):
async_closers.append(ai.aclose())
elif hasattr(ai, "close") and callable(getattr(ai, "close")):
sync_closers.append(ai.close)
# Remove reference so a fresh one is created next time
big.pop("audio_input", None)
if async_closers:
await asyncio.gather(*async_closers, return_exceptions=True)
for fn in sync_closers:
try:
fn()
except Exception:
pass
# Reset PortAudio to drop lingering PipeWire capture nodes
try:
import sounddevice as _sd
if hasattr(_sd, "_terminate"):
_sd._terminate()
await asyncio.sleep(0.05)
if hasattr(_sd, "_initialize"):
_sd._initialize()
except Exception:
pass
async def stream(self):
@@ -388,6 +447,8 @@ class Streamer():
big['audio_input'] = audio_source
big['encoder'] = encoder
big['precoded'] = False
# Prepare frames generator for graceful shutdown
big['frames_gen'] = big['audio_input'].frames(lc3_frame_samples)
elif audio_source == 'webrtc':
big['audio_input'] = WebRTCAudioInput()
@@ -403,6 +464,8 @@ class Streamer():
big['lc3_bytes_per_frame'] = global_config.octets_per_frame
big['encoder'] = encoder
big['precoded'] = False
# Prepare frames generator for graceful shutdown
big['frames_gen'] = big['audio_input'].frames(lc3_frame_samples)
# precoded lc3 from ram
elif isinstance(big_config[i].audio_source, bytes):
@@ -573,7 +636,12 @@ class Streamer():
stream_finished[i] = True
continue
else: # code lc3 on the fly
pcm_frame = await anext(big['audio_input'].frames(big['lc3_frame_samples']), None)
# Use stored frames generator when available so we can aclose() it on stop
frames_gen = big.get('frames_gen')
if frames_gen is None:
frames_gen = big['audio_input'].frames(big['lc3_frame_samples'])
big['frames_gen'] = frames_gen
pcm_frame = await anext(frames_gen, None)
if pcm_frame is None: # Not all streams may stop at the same time
stream_finished[i] = True
@@ -674,7 +742,7 @@ if __name__ == "__main__":
# TODO: encrypted streams are not working
for big in config.bigs:
#big.code = 'ff'*16 # returns hci/HCI_ENCRYPTION_MODE_NOT_ACCEPTABLE_ERROR
#big.code = 'abcd'
#big.code = '78 e5 dc f1 34 ab 42 bf c1 92 ef dd 3a fd 67 ae'
big.precode_wav = False
#big.audio_source = big.audio_source.replace('.wav', '_10_16_32.lc3') #lc3 precoded files

View File

@@ -91,6 +91,8 @@ class Multicaster:
for big in self.bigs.values():
if big.get('advertising_set'):
await big['advertising_set'].stop()
# Explicitly power off the device to ensure a clean state before closing the transport
await self.device.power_off()
await self.device_acm.__aexit__(None, None, None) # Manually triggering teardown

View File

@@ -44,7 +44,11 @@ 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
from auracast.utils.sounddevice_utils import (
get_usb_pw_inputs,
get_network_pw_inputs,
refresh_pw_cache,
)
if __name__ == "__main__":
@@ -59,12 +63,14 @@ if __name__ == "__main__":
os.environ.setdefault("PULSE_LATENCY_MSEC", "3")
usb_inputs = list_usb_pw_inputs()
# Refresh device cache and list inputs
refresh_pw_cache()
usb_inputs = get_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()
aes67_inputs = get_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']}")
@@ -85,7 +91,8 @@ if __name__ == "__main__":
if iface_substr:
# Loop until a matching AES67 input becomes available
while True:
current = list_network_pw_inputs()
refresh_pw_cache()
current = get_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]
@@ -100,7 +107,8 @@ if __name__ == "__main__":
else:
# Loop until a USB input becomes available (mirror AES67 retry behavior)
while True:
current = list_usb_pw_inputs()
refresh_pw_cache()
current = get_usb_pw_inputs()
if current:
input_sel, selected_dev = current[0]
logging.info(f"Selected first USB input: index={input_sel}, device={selected_dev['name']}")
@@ -146,11 +154,12 @@ if __name__ == "__main__":
presentation_delay_us=40000,
qos_config=auracast_config.AuracastQosHigh(),
auracast_sampling_rate_hz = LC3_SRATE,
octets_per_frame = OCTETS_PER_FRAME, # 32kbps@16kHz
octets_per_frame = OCTETS_PER_FRAME,
transport=TRANSPORT1
)
#config.debug = True
logging.info(config.model_dump_json(indent=2))
multicast.run_async(
multicast.broadcast(
config,

View File

@@ -1,23 +1,88 @@
# frontend/app.py
import os
import time
import streamlit as st
import requests
from auracast import auracast_config
import logging as log
from PIL import Image
import requests
from dotenv import load_dotenv
import streamlit as st
from auracast import auracast_config
from auracast.utils.frontend_auth import (
is_pw_disabled,
load_pw_record,
save_pw_record,
hash_password,
verify_password,
)
# Set page configuration (tab title and icon) before using other Streamlit APIs
# Always use the favicon from the utils folder relative to this file
_THIS_DIR = os.path.dirname(__file__)
_FAVICON_PATH = os.path.abspath(os.path.join(_THIS_DIR, '..', 'utils', 'favicon.ico'))
favicon = Image.open(_FAVICON_PATH)
st.set_page_config(page_title="Castbox", page_icon=favicon, layout="centered")
# Load environment variables from a .env file if present
load_dotenv()
# Track whether WebRTC stream is active across Streamlit reruns
if 'stream_started' not in st.session_state:
st.session_state['stream_started'] = False
# Frontend authentication gate is controlled via env using shared utils
if 'frontend_authenticated' not in st.session_state:
st.session_state['frontend_authenticated'] = False
if not is_pw_disabled():
pw_rec = load_pw_record()
# First-time setup: no password set -> force user to choose one
if pw_rec is None:
st.header("Set up your frontend password")
st.info("For security, you must set a password on first access.")
with st.form("first_setup_form"):
new_pw = st.text_input("New password", type="password")
new_pw2 = st.text_input("Confirm password", type="password")
submitted = st.form_submit_button("Save password")
if submitted:
if len(new_pw) < 6:
st.error("Password should be at least 6 characters.")
elif new_pw != new_pw2:
st.error("Passwords do not match.")
else:
salt, key = hash_password(new_pw)
try:
save_pw_record(salt, key)
st.success("Password saved. You can now sign in.")
st.rerun()
except Exception as e:
st.error(f"Failed to save password: {e}")
st.stop()
# Normal sign-in gate
if not st.session_state['frontend_authenticated']:
st.header("Sign in")
with st.form("signin_form"):
pw = st.text_input("Password", type="password")
submitted = st.form_submit_button("Sign in")
if submitted:
if verify_password(pw, pw_rec):
st.session_state['frontend_authenticated'] = True
st.success("Signed in.")
st.rerun()
else:
st.error("Incorrect password. Please try again.")
# Stop rendering the rest of the app until authenticated
if not st.session_state['frontend_authenticated']:
st.stop()
# Global: desired packetization time in ms for Opus (should match backend)
PTIME = 40
BACKEND_URL = "http://localhost:5000"
#TRANSPORT1 = "serial:/dev/serial/by-id/usb-ZEPHYR_Zephyr_HCI_UART_sample_B53C372677E14460-if00,115200,rtscts"
#TRANSPORT2 = "serial:/dev/serial/by-id/usb-ZEPHYR_Zephyr_HCI_UART_sample_CC69A2912F84AE5E-if00,115200,rtscts"
TRANSPORT1 = 'serial:/dev/ttyAMA3,1000000,rtscts' # transport for raspberry pi gpio header
TRANSPORT2 = 'serial:/dev/ttyAMA4,1000000,rtscts' # transport for raspberry pi gpio header
QUALITY_MAP = {
"High (48kHz)": {"rate": 48000, "octets": 120},
"Good (32kHz)": {"rate": 32000, "octets": 80},
@@ -34,19 +99,34 @@ try:
except Exception:
saved_settings = {}
st.title("🎙️ Auracast Audio Mode Control")
# Define is_streaming early from the fetched status for use throughout the UI
is_streaming = bool(saved_settings.get("is_streaming", False))
st.title("Auracast Audio Mode Control")
# Audio mode selection with persisted default
options = ["Webapp", "USB/Network", "Demo"]
saved_audio_mode = saved_settings.get("audio_mode", "Webapp")
# Note: backend persists 'USB' for any device:<name> source (including AES67). We default to 'USB' in that case.
options = [
"Demo",
"USB",
"AES67",
# "Webapp"
]
saved_audio_mode = saved_settings.get("audio_mode", "Demo")
if saved_audio_mode not in options:
saved_audio_mode = "Webapp"
# Map legacy/unknown modes to closest
mapping = {"USB/Network": "USB", "Network": "AES67"}
saved_audio_mode = mapping.get(saved_audio_mode, "Demo")
audio_mode = st.selectbox(
"Audio Mode",
options,
index=options.index(saved_audio_mode),
help="Select the audio input source. Choose 'Webapp' for browser microphone, 'USB/Network' for a connected hardware device, or 'Demo' for a simulated stream."
index=options.index(saved_audio_mode) if saved_audio_mode in options else options.index("Demo"),
help=(
"Select the audio input source. Choose 'Webapp' for browser microphone, "
"'USB' for a connected USB audio device (via PipeWire), 'AES67' for network RTP/AES67 sources, "
"or 'Demo' for a simulated stream."
)
)
if audio_mode == "Demo":
@@ -66,15 +146,48 @@ if audio_mode == "Demo":
index=0,
help="Select the demo stream configuration."
)
# Stream password and flags (same as USB/AES67)
saved_pwd = saved_settings.get('stream_password', '') or ''
stream_passwort = st.text_input(
"Stream Passwort",
value=saved_pwd,
type=("password"),
help="Optional: Set a broadcast code to protect your stream. Leave empty for an open (uncoded) broadcast."
)
col_flags1, col_flags2, col_pdelay, col_rtn = st.columns([1, 1, 1, 1], gap="small")
with col_flags1:
assisted_listening = st.checkbox(
"Assistive listening",
value=bool(saved_settings.get('assisted_listening_stream', False))
)
with col_flags2:
immediate_rendering = st.checkbox(
"Immediate rendering",
value=bool(saved_settings.get('immediate_rendering', False))
)
# QoS/presentation controls inline with flags
default_pdelay = int(saved_settings.get('presentation_delay_us', 40000) or 40000)
with col_pdelay:
presentation_delay_us = st.number_input(
"Presentation delay (µs)",
min_value=10000, max_value=200000, step=1000, value=default_pdelay,
help="Delay between capture and presentation for receivers."
)
default_rtn = int(saved_settings.get('rtn', 4) or 4)
with col_rtn:
rtn = st.selectbox(
"Retransmissions (RTN)", options=[0,1,2,3,4], index=[0,1,2,3,4].index(default_rtn),
help="Number of ISO retransmissions (higher improves robustness at cost of airtime)."
)
#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")
start_demo = st.button("Start Demo Stream", disabled=is_streaming)
with col2:
stop_demo = st.button("Stop Demo Stream")
stop_demo = st.button("Stop Demo Stream", disabled=not is_streaming)
if start_demo:
# Always stop any running stream for clean state
try:
@@ -99,6 +212,7 @@ if audio_mode == "Demo":
for i in range(demo_cfg['streams']):
cfg_cls, lang = lang_cfgs[i % len(lang_cfgs)]
bigs1.append(cfg_cls(
code=(stream_passwort.strip() or None),
audio_source=f'file:../testdata/wave_particle_5min_{lang}_{int(q["rate"]/1000)}kHz_mono.wav',
iso_que_len=32,
sampling_frequency=q['rate'],
@@ -115,7 +229,15 @@ if audio_mode == "Demo":
config1 = auracast_config.AuracastConfigGroup(
auracast_sampling_rate_hz=q['rate'],
octets_per_frame=q['octets'],
transport=TRANSPORT1,
transport='', # is set in baccol_qoskend
assisted_listening_stream=assisted_listening,
immediate_rendering=immediate_rendering,
presentation_delay_us=presentation_delay_us,
qos_config=auracast_config.AuracastQoSConfig(
iso_int_multiple_10ms=1,
number_of_retransmissions=int(rtn),
max_transport_latency_ms=int(rtn)*10 + 3,
),
bigs=bigs1
)
config2 = None
@@ -123,7 +245,15 @@ if audio_mode == "Demo":
config2 = auracast_config.AuracastConfigGroup(
auracast_sampling_rate_hz=q['rate'],
octets_per_frame=q['octets'],
transport=TRANSPORT2,
transport='', # is set in backend
assisted_listening_stream=assisted_listening,
immediate_rendering=immediate_rendering,
presentation_delay_us=presentation_delay_us,
qos_config=auracast_config.AuracastQoSConfig(
iso_int_multiple_10ms=1,
number_of_retransmissions=int(rtn),
max_transport_latency_ms=int(rtn)*10 + 3,
),
bigs=bigs2
)
# Call /init and /init2
@@ -151,6 +281,7 @@ if audio_mode == "Demo":
st.session_state['demo_stream_started'] = False
if r.get('was_running'):
st.info("Demo stream stopped.")
st.rerun()
else:
st.info("Demo stream was not running.")
except Exception as e:
@@ -194,61 +325,133 @@ else:
value=default_lang,
help="Three-letter language code (e.g., 'eng' for English, 'deu' for German). Used by receivers to display the language of the stream. See: https://en.wikipedia.org/wiki/List_of_ISO_639-3_codes"
)
# Optional broadcast code for coded streams
stream_passwort = st.text_input(
"Stream Passwort",
value="",
type="password",
help="Optional: Set a broadcast code to protect your stream. Leave empty for an open (uncoded) broadcast."
)
# Flags and QoS row (compact, four columns)
col_flags1, col_flags2, col_pdelay, col_rtn = st.columns([1, 1, 1, 1], gap="small")
with col_flags1:
assisted_listening = st.checkbox(
"Assistive listening",
value=bool(saved_settings.get('assisted_listening_stream', False))
)
with col_flags2:
immediate_rendering = st.checkbox(
"Immediate rendering",
value=bool(saved_settings.get('immediate_rendering', False))
)
# QoS/presentation controls inline with flags
default_pdelay = int(saved_settings.get('presentation_delay_us', 40000) or 40000)
with col_pdelay:
presentation_delay_us = st.number_input(
"Presentation delay (µs)",
min_value=10000, max_value=200000, step=1000, value=default_pdelay,
help="Delay between capture and presentation for receivers."
)
default_rtn = int(saved_settings.get('rtn', 4) or 4)
with col_rtn:
rtn = st.selectbox(
"Retransmissions (RTN)", options=[0,1,2,3,4], index=[0,1,2,3,4].index(default_rtn),
help="Number of ISO retransmissions (higher improves robustness at cost of airtime)."
)
# 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]
# Input device selection for USB or AES67 mode
if audio_mode in ("USB", "AES67"):
if not is_streaming:
# Only query device lists when NOT streaming to avoid extra backend calls
try:
endpoint = "/audio_inputs_pw_usb" if audio_mode == "USB" else "/audio_inputs_pw_network"
resp = requests.get(f"{BACKEND_URL}{endpoint}")
device_list = resp.json().get('inputs', [])
except Exception as e:
st.error(f"Failed to fetch devices: {e}")
device_list = []
# 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
# 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 (from persisted server state)
default_input_name = saved_settings.get('input_device')
if default_input_name not in device_names and device_names:
default_input_name = device_names[0]
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:
warn_text = (
"No USB audio input devices found. Connect a USB input and click Refresh."
if audio_mode == "USB" else
"No AES67/Network inputs found."
)
with col2:
if st.button("Refresh"):
st.warning(warn_text)
if st.button("Refresh", disabled=is_streaming):
try:
requests.post(f"{BACKEND_URL}/refresh_audio_inputs", timeout=3)
r = requests.post(f"{BACKEND_URL}/refresh_audio_devices", timeout=8)
if not r.ok:
st.error(f"Failed to refresh: {r.text}")
except Exception as e:
st.error(f"Failed to refresh devices: {e}")
st.rerun()
# Send only the device name to backend
input_device = option_name_map[selected_option] if selected_option in option_name_map else None
input_device = 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", disabled=is_streaming):
try:
r = requests.post(f"{BACKEND_URL}/refresh_audio_devices", timeout=8)
if not r.ok:
st.error(f"Failed to refresh: {r.text}")
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.get(selected_option)
else:
# When streaming, keep showing the current selection but lock editing.
input_device = saved_settings.get('input_device')
current_label = input_device or "No device selected"
st.selectbox(
"Input Device",
[current_label],
index=0,
disabled=True,
help="Stop the stream to change the input device."
)
else:
input_device = None
start_stream = st.button("Start Auracast")
stop_stream = st.button("Stop Auracast")
# Buttons and status on a single row (4 columns: start, stop, spacer, status)
c_start, c_stop, c_spacer, c_status = st.columns([1, 1, 1, 2], gap="small", vertical_alignment="center")
with c_start:
start_stream = st.button("Start Auracast", disabled=is_streaming)
with c_stop:
stop_stream = st.button("Stop Auracast", disabled=not is_streaming)
# c_spacer intentionally left empty to push status to the far right
with c_status:
# Fetch current status from backend and render using Streamlit widgets (no HTML)
# The is_streaming variable is now defined at the top of the script.
# We only need to re-fetch here if we want the absolute latest status for the display,
# but for UI consistency, we can just use the value from the top of the script run.
st.write("🟢 Streaming" if is_streaming else "🔴 Stopped")
# If gain slider moved while streaming, send update to JS without restarting
if audio_mode == "Webapp" and st.session_state.get('stream_started'):
@@ -265,6 +468,7 @@ else:
r = requests.post(f"{BACKEND_URL}/stop_audio").json()
if r['was_running']:
st.success("Stream Stopped!")
st.rerun()
else:
st.success("Stream was not running.")
except Exception as e:
@@ -287,8 +491,8 @@ else:
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!")
#if r['was_running']:
# st.success("Stream Stopped!")
# Small pause lets backend fully release audio devices before re-init
time.sleep(1)
@@ -297,18 +501,27 @@ else:
config = auracast_config.AuracastConfigGroup(
auracast_sampling_rate_hz=q['rate'],
octets_per_frame=q['octets'],
transport=TRANSPORT1, # transport for raspberry pi gpio header
transport='', # is set in backend
assisted_listening_stream=assisted_listening,
immediate_rendering=immediate_rendering,
presentation_delay_us=presentation_delay_us,
qos_config=auracast_config.AuracastQoSConfig(
iso_int_multiple_10ms=1,
number_of_retransmissions=int(rtn),
max_transport_latency_ms=int(rtn)*10 + 3,
),
bigs = [
auracast_config.AuracastBigConfig(
code=(stream_passwort.strip() or None),
name=stream_name,
program_info=program_info,
language=language,
audio_source=(
f"device:{input_device}" if audio_mode == "USB/Network" else (
f"device:{input_device}" if audio_mode in ("USB", "AES67") else (
"webrtc" if audio_mode == "Webapp" else "network"
)
),
input_format=(f"int16le,{q['rate']},1" if audio_mode == "USB/Network" else "auto"),
input_format=(f"int16le,{q['rate']},1" if audio_mode in ("USB", "AES67") else "auto"),
iso_que_len=1,
sampling_frequency=q['rate'],
octets_per_frame=q['octets'],
@@ -320,6 +533,7 @@ else:
r = requests.post(f"{BACKEND_URL}/init", json=config.model_dump())
if r.status_code == 200:
st.success("Stream Started!")
st.rerun()
else:
st.error(f"Failed to initialize: {r.text}")
except Exception as e:
@@ -393,6 +607,47 @@ else:
# else:
# st.error("Could not fetch advertised streams.")
############################
# System expander (collapsed)
############################
with st.expander("System control", expanded=False):
st.subheader("Change password")
if is_pw_disabled():
st.info("Frontend password protection is disabled via DISABLE_FRONTEND_PW.")
else:
with st.form("change_pw_form"):
cur = st.text_input("Current password", type="password")
new1 = st.text_input("New password", type="password")
new2 = st.text_input("Confirm new password", type="password")
submit_change = st.form_submit_button("Change password")
if submit_change:
rec = load_pw_record()
if not rec or not verify_password(cur, rec):
st.error("Current password is incorrect.")
elif len(new1) < 6:
st.error("New password should be at least 6 characters.")
elif new1 != new2:
st.error("New passwords do not match.")
else:
salt, key = hash_password(new1)
try:
save_pw_record(salt, key)
st.success("Password updated.")
except Exception as e:
st.error(f"Failed to update password: {e}")
st.subheader("Reboot")
if st.button("Reboot now", type="primary"):
try:
r = requests.post(f"{BACKEND_URL}/system_reboot", timeout=1)
if r.ok:
st.success("Reboot initiated. The UI will become unreachable shortly.")
else:
st.error(f"Failed to reboot: {r.status_code} {r.text}")
except Exception as e:
st.error(f"Error calling reboot: {e}")
log.basicConfig(
level=os.environ.get('LOG_LEVEL', log.DEBUG),
format='%(module)s.py:%(lineno)d %(levelname)s: %(message)s'

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -0,0 +1,96 @@
import os
import json
import base64
import hashlib
import hmac
from pathlib import Path
from typing import Optional, Tuple, Dict
__all__ = [
"is_pw_disabled",
"state_dir",
"pw_file_path",
"ensure_state_dir",
"hash_password",
"save_pw_record",
"load_pw_record",
"verify_password",
]
# Environment-controlled bypass
def is_pw_disabled() -> bool:
val = os.getenv("DISABLE_FRONTEND_PW", "")
return str(val).strip().lower() in ("1", "true", "yes", "on")
# Storage paths and permissions
def state_dir() -> Path:
custom = os.getenv("AURACAST_STATE_DIR")
if custom:
return Path(custom).expanduser()
return Path.home() / ".config" / "auracast"
def pw_file_path() -> Path:
return state_dir() / "frontend_pw.json"
def ensure_state_dir() -> None:
d = state_dir()
d.mkdir(parents=True, exist_ok=True)
try:
os.chmod(d, 0o700)
except Exception:
pass
# Hashing and verification
def hash_password(password: str, salt: Optional[bytes] = None) -> Tuple[bytes, bytes]:
if salt is None:
salt = os.urandom(16)
key = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, 150_000, dklen=32)
return salt, key
def save_pw_record(salt: bytes, key: bytes) -> None:
ensure_state_dir()
rec = {
"salt": base64.b64encode(salt).decode("ascii"),
"key": base64.b64encode(key).decode("ascii"),
"kdf": "pbkdf2_sha256",
"iterations": 150000,
}
p = pw_file_path()
p.write_text(json.dumps(rec))
try:
os.chmod(p, 0o600)
except Exception:
pass
def load_pw_record() -> Optional[Dict]:
p = pw_file_path()
if not p.exists():
return None
try:
rec = json.loads(p.read_text())
if "salt" in rec and "key" in rec:
return rec
except Exception:
return None
return None
def verify_password(password: str, rec: Dict) -> bool:
try:
salt = base64.b64decode(rec["salt"])
expected = base64.b64decode(rec["key"])
iters = int(rec.get("iterations", 150000))
key = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, iters, dklen=32)
return hmac.compare_digest(key, expected)
except Exception:
return False

View File

@@ -0,0 +1,77 @@
import os
import asyncio
import logging as log
async def reset_nrf54l(slot: int = 0, timeout: float = 8.0):
"""
Reset the nRF54L target using OpenOCD before starting broadcast.
Looks for interface config files in the project at `src/openocd/` relative to this module only.
Accepts filename variants per slot:
- slot 0: raspberrypi-swd0.cfg or swd0.cfg
- slot 1: raspberrypi-swd1.cfg or swd1.cfg
Executes the equivalent of:
openocd \
-f ./raspberrypi-${INTERFACE}.cfg \
-f target/nordic/nrf54l.cfg \
-c "init" \
-c "reset run" \
-c "shutdown"
Best-effort: if OpenOCD is unavailable, logs a warning and continues.
"""
try:
# Resolve project directory and filenames
proj_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..', 'openocd'))
names = ['raspberrypi-swd0.cfg', 'swd0.cfg'] if slot == 0 else ['raspberrypi-swd1.cfg', 'swd1.cfg']
cfg = None
for n in names:
p = os.path.join(proj_dir, n)
if os.path.exists(p):
cfg = p
break
if not cfg:
log.warning("reset_nrf54l: no interface CFG found in project dir %s; skipping reset", proj_dir)
return
# Build openocd command (no sudo required as per project setup).
cmd = ['openocd', '-f', cfg, '-f', 'target/nordic/nrf54l.cfg',
'-c', 'init', '-c', 'reset run', '-c', 'shutdown']
async def _run(cmd):
proc = await asyncio.create_subprocess_exec(
*cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT
)
try:
out, _ = await asyncio.wait_for(proc.communicate(), timeout=timeout)
except asyncio.TimeoutError:
log.error("reset_nrf54l: %s timed out; terminating", cmd[0])
proc.kill()
return False
rc = proc.returncode
if rc != 0:
log.error("reset_nrf54l: %s exited with code %s; output: %s", cmd[0], rc, (out or b'').decode(errors='ignore'))
return False
return True
ok = await _run(cmd)
if ok:
log.info("reset_nrf54l: reset succeeded (slot %d) using %s", slot, cfg)
except FileNotFoundError:
log.error("reset_nrf54l: openocd not found; skipping reset")
except Exception:
log.error("reset_nrf54l failed", exc_info=True)
if __name__ == '__main__':
# Basic logging setup
log.basicConfig(
level=os.environ.get('LOG_LEVEL', log.INFO),
format='%(asctime)s.%(msecs)03d %(levelname)s: %(message)s',
datefmt='%Y-%m-%d %H:%M:%S'
)
slot_to_reset = 1
log.info(f"Executing reset for slot {slot_to_reset}")
asyncio.run(reset_nrf54l(slot=slot_to_reset))

View File

@@ -42,58 +42,47 @@ def _sd_matches_from_names(pa_idx, names):
if d["hostapi"] != pa_idx or d["max_input_channels"] <= 0:
continue
dn = d["name"].lower()
# Exclude monitor devices (e.g., "Monitor of ...") to avoid false positives
if "monitor" in dn:
continue
if any(n in dn for n in names_l):
out.append((i, d))
return out
def list_usb_pw_inputs():
# Module-level caches for device lists
_usb_inputs_cache = []
_network_inputs_cache = []
def get_usb_pw_inputs():
"""Return cached list of USB PipeWire inputs."""
return _usb_inputs_cache
def get_network_pw_inputs():
"""Return cached list of Network/AES67 PipeWire inputs."""
return _network_inputs_cache
def refresh_pw_cache():
"""
Return [(device_index, device_dict), ...] for PipeWire **input** nodes
backed by **USB** devices (excludes monitor sources).
Performs a full device scan and updates the internal caches for both USB
and Network audio devices. This is a heavy operation and should not be
called frequently or during active streams.
"""
# Refresh PortAudio so we see newly added nodes before mapping
global _usb_inputs_cache, _network_inputs_cache
# Force PortAudio to re-enumerate devices
_sd_refresh()
pa_idx = _pa_like_hostapi_index()
pw = _pw_dump()
# Map device.id -> device.bus ("usb"/"pci"/"platform"/"network"/...)
# --- Pass 1: Map device.id to device.bus ---
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
# --- Pass 2: Identify all USB and Network 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":
@@ -102,26 +91,29 @@ def list_network_pw_inputs():
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
# Skip all monitor sources
if ".monitor" in nname.lower() or "monitor" in ndesc.lower():
continue
# Heuristics for network/AES67/RTP
# Check for USB
bus = (props.get("device.bus") or device_bus.get(props.get("device.id")) or "").lower()
if bus == "usb":
usb_input_names.add(ndesc or nname)
continue # A device is either USB or Network, not both
# 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"
))
has_rtp_keys = any(k in props for k in ("rtp.session", "rtp.source.ip"))
has_sess_keys = any(k in props for k in ("sess.name", "sess.media"))
is_network = (
(props.get("device.bus") or "").lower() == "network" or
bus == "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
@@ -132,7 +124,13 @@ def list_network_pw_inputs():
if is_network:
network_input_names.add(ndesc or nname)
return _sd_matches_from_names(pa_idx, network_input_names)
# --- Final Step: Update caches ---
_usb_inputs_cache = _sd_matches_from_names(pa_idx, usb_input_names)
_network_inputs_cache = _sd_matches_from_names(pa_idx, network_input_names)
# Populate cache on initial module load
refresh_pw_cache()
# Example usage:
# for i, d in list_usb_pw_inputs():