feature/autostart (#7)
implement auto restart of last stream fix aes67 streaming basic webinterface and many more Co-authored-by: pstruebi <struebin.patrick.com> Reviewed-on: https://gitea.pstruebi.xyz/auracaster/bumble-auracast/pulls/7
This commit was merged in pull request #7.
This commit is contained in:
@@ -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, "
|
||||
|
||||
@@ -45,7 +45,7 @@ import bumble.device
|
||||
import bumble.transport
|
||||
import bumble.utils
|
||||
import numpy as np # for audio down-mix
|
||||
from bumble.device import Host, BIGInfoAdvertisement, AdvertisingChannelMap
|
||||
from bumble.device import Host, AdvertisingChannelMap
|
||||
from bumble.audio import io as audio_io
|
||||
|
||||
from auracast import auracast_config
|
||||
@@ -101,6 +101,24 @@ class ModWaveAudioInput(audio_io.ThreadedAudioInput):
|
||||
audio_io.WaveAudioInput = ModWaveAudioInput
|
||||
|
||||
|
||||
def broadcast_code_bytes(broadcast_code: str) -> bytes:
|
||||
"""
|
||||
Convert a broadcast code string to a 16-byte value.
|
||||
|
||||
If `broadcast_code` is `0x` followed by 32 hex characters, it is interpreted as a
|
||||
raw 16-byte raw broadcast code in big-endian byte order.
|
||||
Otherwise, `broadcast_code` is converted to a 16-byte value as specified in
|
||||
BLUETOOTH CORE SPECIFICATION Version 6.0 | Vol 3, Part C , section 3.2.6.3
|
||||
"""
|
||||
if broadcast_code.startswith("0x") and len(broadcast_code) == 34:
|
||||
return bytes.fromhex(broadcast_code[2:])[::-1]
|
||||
|
||||
broadcast_code_utf8 = broadcast_code.encode("utf-8")
|
||||
if len(broadcast_code_utf8) > 16:
|
||||
raise ValueError("broadcast code must be <= 16 bytes in utf-8 encoding")
|
||||
padding = bytes(16 - len(broadcast_code_utf8))
|
||||
return broadcast_code_utf8 + padding
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Logging
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -170,7 +188,15 @@ async def init_broadcast(
|
||||
# Broadcast Audio Immediate Rendering flag (type 0x09), zero-length value
|
||||
le_audio.Metadata.Entry(tag = le_audio.Metadata.Tag.BROADCAST_AUDIO_IMMEDIATE_RENDERING_FLAG, data=b"")
|
||||
]
|
||||
if global_config.immediate_rendering #TODO: verify this
|
||||
if global_config.immediate_rendering
|
||||
else []
|
||||
)
|
||||
+ (
|
||||
[
|
||||
# Assisted Listening Stream tag expects a 1-octet value. Use 0x01 to indicate enabled.
|
||||
le_audio.Metadata.Entry(tag = le_audio.Metadata.Tag.ASSISTED_LISTENING_STREAM, data=b"\x01")
|
||||
]
|
||||
if global_config.assisted_listening_stream
|
||||
else []
|
||||
)
|
||||
)
|
||||
@@ -269,7 +295,7 @@ async def init_broadcast(
|
||||
max_transport_latency=global_config.qos_config.max_transport_latency_ms,
|
||||
rtn=global_config.qos_config.number_of_retransmissions,
|
||||
broadcast_code=(
|
||||
bytes.fromhex(conf.code) if conf.code else None
|
||||
broadcast_code_bytes(conf.code) if conf.code else None
|
||||
),
|
||||
framing=frame_enable # needed if iso interval is not frame interval of codedc
|
||||
),
|
||||
@@ -674,7 +700,7 @@ if __name__ == "__main__":
|
||||
# TODO: encrypted streams are not working
|
||||
|
||||
for big in config.bigs:
|
||||
#big.code = 'ff'*16 # returns hci/HCI_ENCRYPTION_MODE_NOT_ACCEPTABLE_ERROR
|
||||
#big.code = 'abcd'
|
||||
#big.code = '78 e5 dc f1 34 ab 42 bf c1 92 ef dd 3a fd 67 ae'
|
||||
big.precode_wav = False
|
||||
#big.audio_source = big.audio_source.replace('.wav', '_10_16_32.lc3') #lc3 precoded files
|
||||
|
||||
@@ -1,23 +1,88 @@
|
||||
# frontend/app.py
|
||||
import os
|
||||
import time
|
||||
import streamlit as st
|
||||
import requests
|
||||
from auracast import auracast_config
|
||||
import logging as log
|
||||
from PIL import Image
|
||||
|
||||
import requests
|
||||
from dotenv import load_dotenv
|
||||
import streamlit as st
|
||||
|
||||
from auracast import auracast_config
|
||||
from auracast.utils.frontend_auth import (
|
||||
is_pw_disabled,
|
||||
load_pw_record,
|
||||
save_pw_record,
|
||||
hash_password,
|
||||
verify_password,
|
||||
)
|
||||
|
||||
# Set page configuration (tab title and icon) before using other Streamlit APIs
|
||||
# Always use the favicon from the utils folder relative to this file
|
||||
_THIS_DIR = os.path.dirname(__file__)
|
||||
_FAVICON_PATH = os.path.abspath(os.path.join(_THIS_DIR, '..', 'utils', 'favicon.ico'))
|
||||
favicon = Image.open(_FAVICON_PATH)
|
||||
st.set_page_config(page_title="Castbox", page_icon=favicon, layout="centered")
|
||||
|
||||
# Load environment variables from a .env file if present
|
||||
load_dotenv()
|
||||
|
||||
# Track whether WebRTC stream is active across Streamlit reruns
|
||||
if 'stream_started' not in st.session_state:
|
||||
st.session_state['stream_started'] = False
|
||||
|
||||
# Frontend authentication gate is controlled via env using shared utils
|
||||
|
||||
if 'frontend_authenticated' not in st.session_state:
|
||||
st.session_state['frontend_authenticated'] = False
|
||||
|
||||
if not is_pw_disabled():
|
||||
pw_rec = load_pw_record()
|
||||
|
||||
# First-time setup: no password set -> force user to choose one
|
||||
if pw_rec is None:
|
||||
st.header("Set up your frontend password")
|
||||
st.info("For security, you must set a password on first access.")
|
||||
with st.form("first_setup_form"):
|
||||
new_pw = st.text_input("New password", type="password")
|
||||
new_pw2 = st.text_input("Confirm password", type="password")
|
||||
submitted = st.form_submit_button("Save password")
|
||||
if submitted:
|
||||
if len(new_pw) < 6:
|
||||
st.error("Password should be at least 6 characters.")
|
||||
elif new_pw != new_pw2:
|
||||
st.error("Passwords do not match.")
|
||||
else:
|
||||
salt, key = hash_password(new_pw)
|
||||
try:
|
||||
save_pw_record(salt, key)
|
||||
st.success("Password saved. You can now sign in.")
|
||||
st.rerun()
|
||||
except Exception as e:
|
||||
st.error(f"Failed to save password: {e}")
|
||||
st.stop()
|
||||
|
||||
# Normal sign-in gate
|
||||
if not st.session_state['frontend_authenticated']:
|
||||
st.header("Sign in")
|
||||
with st.form("signin_form"):
|
||||
pw = st.text_input("Password", type="password")
|
||||
submitted = st.form_submit_button("Sign in")
|
||||
if submitted:
|
||||
if verify_password(pw, pw_rec):
|
||||
st.session_state['frontend_authenticated'] = True
|
||||
st.success("Signed in.")
|
||||
st.rerun()
|
||||
else:
|
||||
st.error("Incorrect password. Please try again.")
|
||||
# Stop rendering the rest of the app until authenticated
|
||||
if not st.session_state['frontend_authenticated']:
|
||||
st.stop()
|
||||
|
||||
# Global: desired packetization time in ms for Opus (should match backend)
|
||||
PTIME = 40
|
||||
BACKEND_URL = "http://localhost:5000"
|
||||
#TRANSPORT1 = "serial:/dev/serial/by-id/usb-ZEPHYR_Zephyr_HCI_UART_sample_B53C372677E14460-if00,115200,rtscts"
|
||||
#TRANSPORT2 = "serial:/dev/serial/by-id/usb-ZEPHYR_Zephyr_HCI_UART_sample_CC69A2912F84AE5E-if00,115200,rtscts"
|
||||
|
||||
TRANSPORT1 = 'serial:/dev/ttyAMA3,1000000,rtscts' # transport for raspberry pi gpio header
|
||||
TRANSPORT2 = 'serial:/dev/ttyAMA4,1000000,rtscts' # transport for raspberry pi gpio header
|
||||
QUALITY_MAP = {
|
||||
"High (48kHz)": {"rate": 48000, "octets": 120},
|
||||
"Good (32kHz)": {"rate": 32000, "octets": 80},
|
||||
@@ -34,19 +99,26 @@ try:
|
||||
except Exception:
|
||||
saved_settings = {}
|
||||
|
||||
st.title("🎙️ Auracast Audio Mode Control")
|
||||
st.title("Auracast Audio Mode Control")
|
||||
|
||||
# Audio mode selection with persisted default
|
||||
options = ["Webapp", "USB/Network", "Demo"]
|
||||
saved_audio_mode = saved_settings.get("audio_mode", "Webapp")
|
||||
# Note: backend persists 'USB' for any device:<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,6 +138,25 @@ if audio_mode == "Demo":
|
||||
index=0,
|
||||
help="Select the demo stream configuration."
|
||||
)
|
||||
# Stream password and flags (same as USB/AES67)
|
||||
saved_pwd = saved_settings.get('stream_password', '') or ''
|
||||
stream_passwort = st.text_input(
|
||||
"Stream Passwort",
|
||||
value=saved_pwd,
|
||||
type=("password"),
|
||||
help="Optional: Set a broadcast code to protect your stream. Leave empty for an open (uncoded) broadcast."
|
||||
)
|
||||
col_flags1, col_flags2, col_placeholder = st.columns([1, 1, 2])
|
||||
with col_flags1:
|
||||
assisted_listening = st.checkbox(
|
||||
"Assistive listening",
|
||||
value=bool(saved_settings.get('assisted_listening_stream', False))
|
||||
)
|
||||
with col_flags2:
|
||||
immediate_rendering = st.checkbox(
|
||||
"Immediate rendering",
|
||||
value=bool(saved_settings.get('immediate_rendering', False))
|
||||
)
|
||||
#st.info(f"Demo mode selected: {demo_selected} (Streams: {demo_stream_map[demo_selected]['streams']}, Rate: {demo_stream_map[demo_selected]['rate']} Hz)")
|
||||
# Start/Stop buttons for demo mode
|
||||
if 'demo_stream_started' not in st.session_state:
|
||||
@@ -99,6 +190,7 @@ if audio_mode == "Demo":
|
||||
for i in range(demo_cfg['streams']):
|
||||
cfg_cls, lang = lang_cfgs[i % len(lang_cfgs)]
|
||||
bigs1.append(cfg_cls(
|
||||
code=(stream_passwort.strip() or None),
|
||||
audio_source=f'file:../testdata/wave_particle_5min_{lang}_{int(q["rate"]/1000)}kHz_mono.wav',
|
||||
iso_que_len=32,
|
||||
sampling_frequency=q['rate'],
|
||||
@@ -115,7 +207,9 @@ if audio_mode == "Demo":
|
||||
config1 = auracast_config.AuracastConfigGroup(
|
||||
auracast_sampling_rate_hz=q['rate'],
|
||||
octets_per_frame=q['octets'],
|
||||
transport=TRANSPORT1,
|
||||
transport='', # is set in backend
|
||||
assisted_listening_stream=assisted_listening,
|
||||
immediate_rendering=immediate_rendering,
|
||||
bigs=bigs1
|
||||
)
|
||||
config2 = None
|
||||
@@ -123,7 +217,9 @@ if audio_mode == "Demo":
|
||||
config2 = auracast_config.AuracastConfigGroup(
|
||||
auracast_sampling_rate_hz=q['rate'],
|
||||
octets_per_frame=q['octets'],
|
||||
transport=TRANSPORT2,
|
||||
transport='', # is set in backend
|
||||
assisted_listening_stream=assisted_listening,
|
||||
immediate_rendering=immediate_rendering,
|
||||
bigs=bigs2
|
||||
)
|
||||
# Call /init and /init2
|
||||
@@ -151,6 +247,7 @@ if audio_mode == "Demo":
|
||||
st.session_state['demo_stream_started'] = False
|
||||
if r.get('was_running'):
|
||||
st.info("Demo stream stopped.")
|
||||
st.rerun()
|
||||
else:
|
||||
st.info("Demo stream was not running.")
|
||||
except Exception as e:
|
||||
@@ -194,22 +291,47 @@ else:
|
||||
value=default_lang,
|
||||
help="Three-letter language code (e.g., 'eng' for English, 'deu' for German). Used by receivers to display the language of the stream. See: https://en.wikipedia.org/wiki/List_of_ISO_639-3_codes"
|
||||
)
|
||||
# Optional broadcast code for coded streams
|
||||
stream_passwort = st.text_input(
|
||||
"Stream Passwort",
|
||||
value="",
|
||||
type="password",
|
||||
help="Optional: Set a broadcast code to protect your stream. Leave empty for an open (uncoded) broadcast."
|
||||
)
|
||||
# Flags: Assistive Listening and Immediate Rendering (one row)
|
||||
col_flags1, col_flags2, col_placeholder = st.columns([1, 1, 2])
|
||||
with col_flags1:
|
||||
assisted_listening = st.checkbox(
|
||||
"Assistive listening",
|
||||
value=bool(saved_settings.get('assisted_listening_stream', False))
|
||||
)
|
||||
with col_flags2:
|
||||
immediate_rendering = st.checkbox(
|
||||
"Immediate rendering",
|
||||
value=bool(saved_settings.get('immediate_rendering', False))
|
||||
)
|
||||
# Gain slider for Webapp mode
|
||||
if audio_mode == "Webapp":
|
||||
mic_gain = st.slider("Microphone Gain", 0.0, 2.0, 1.0, 0.1, help="Adjust microphone volume sent to Auracast")
|
||||
else:
|
||||
mic_gain = 1.0
|
||||
|
||||
# Input device selection for USB mode
|
||||
if audio_mode == "USB/Network":
|
||||
resp = requests.get(f"{BACKEND_URL}/audio_inputs")
|
||||
device_list = resp.json().get('inputs', [])
|
||||
# Input device selection for USB or AES67 mode
|
||||
if audio_mode in ("USB", "AES67"):
|
||||
try:
|
||||
endpoint = "/audio_inputs_pw_usb" if audio_mode == "USB" else "/audio_inputs_pw_network"
|
||||
resp = requests.get(f"{BACKEND_URL}{endpoint}")
|
||||
device_list = resp.json().get('inputs', [])
|
||||
except Exception as e:
|
||||
st.error(f"Failed to fetch devices: {e}")
|
||||
device_list = []
|
||||
|
||||
# Display "name [id]" but use name as value
|
||||
input_options = [f"{d['name']} [{d['id']}]" for d in device_list]
|
||||
option_name_map = {f"{d['name']} [{d['id']}]": d['name'] for d in device_list}
|
||||
device_names = [d['name'] for d in device_list]
|
||||
|
||||
# Determine default input by name
|
||||
# Determine default input by name (from persisted server state)
|
||||
default_input_name = saved_settings.get('input_device')
|
||||
if default_input_name not in device_names and device_names:
|
||||
default_input_name = device_names[0]
|
||||
@@ -219,10 +341,20 @@ else:
|
||||
default_input_label = label
|
||||
break
|
||||
if not input_options:
|
||||
st.warning("No hardware audio input devices found. Plug in a USB input device and click Refresh.")
|
||||
warn_text = (
|
||||
"No USB audio input devices found. Connect a USB input and click Refresh."
|
||||
if audio_mode == "USB" else
|
||||
"No AES67/Network inputs found."
|
||||
)
|
||||
st.warning(warn_text)
|
||||
if st.button("Refresh"):
|
||||
# For completeness, refresh the general audio cache as well
|
||||
try:
|
||||
requests.post(f"{BACKEND_URL}/refresh_audio_inputs", timeout=3)
|
||||
r = requests.post(f"{BACKEND_URL}/refresh_audio_inputs", json={"force": True}, timeout=5)
|
||||
if r.ok:
|
||||
jr = r.json()
|
||||
if jr.get('stopped_stream'):
|
||||
st.info("An active stream was stopped to perform a full device refresh.")
|
||||
except Exception as e:
|
||||
st.error(f"Failed to refresh devices: {e}")
|
||||
st.rerun()
|
||||
@@ -238,17 +370,35 @@ else:
|
||||
with col2:
|
||||
if st.button("Refresh"):
|
||||
try:
|
||||
requests.post(f"{BACKEND_URL}/refresh_audio_inputs", timeout=3)
|
||||
r = requests.post(f"{BACKEND_URL}/refresh_audio_inputs", json={"force": True}, timeout=5)
|
||||
if r.ok:
|
||||
jr = r.json()
|
||||
if jr.get('stopped_stream'):
|
||||
st.info("An active stream was stopped to perform a full device refresh.")
|
||||
except Exception as e:
|
||||
st.error(f"Failed to refresh devices: {e}")
|
||||
st.rerun()
|
||||
# Send only the device name to backend
|
||||
input_device = option_name_map[selected_option] if selected_option in option_name_map else None
|
||||
input_device = option_name_map.get(selected_option)
|
||||
else:
|
||||
input_device = None
|
||||
|
||||
start_stream = st.button("Start Auracast")
|
||||
stop_stream = st.button("Stop Auracast")
|
||||
# Buttons and status on a single row (4 columns: start, stop, spacer, status)
|
||||
c_start, c_stop, c_spacer, c_status = st.columns([1, 1, 1, 2], gap="small", vertical_alignment="center")
|
||||
with c_start:
|
||||
start_stream = st.button("Start Auracast")
|
||||
with c_stop:
|
||||
stop_stream = st.button("Stop Auracast")
|
||||
# c_spacer intentionally left empty to push status to the far right
|
||||
with c_status:
|
||||
# Fetch current status from backend and render using Streamlit widgets (no HTML)
|
||||
try:
|
||||
status_resp = requests.get(f"{BACKEND_URL}/status", timeout=0.8)
|
||||
status_json = status_resp.json() if status_resp.ok else {}
|
||||
except Exception:
|
||||
status_json = {}
|
||||
is_streaming = bool(status_json.get("is_streaming", False))
|
||||
st.write("🟢 Streaming" if is_streaming else "🔴 Stopped")
|
||||
|
||||
# If gain slider moved while streaming, send update to JS without restarting
|
||||
if audio_mode == "Webapp" and st.session_state.get('stream_started'):
|
||||
@@ -265,6 +415,7 @@ else:
|
||||
r = requests.post(f"{BACKEND_URL}/stop_audio").json()
|
||||
if r['was_running']:
|
||||
st.success("Stream Stopped!")
|
||||
st.rerun()
|
||||
else:
|
||||
st.success("Stream was not running.")
|
||||
except Exception as e:
|
||||
@@ -297,18 +448,21 @@ else:
|
||||
config = auracast_config.AuracastConfigGroup(
|
||||
auracast_sampling_rate_hz=q['rate'],
|
||||
octets_per_frame=q['octets'],
|
||||
transport=TRANSPORT1, # transport for raspberry pi gpio header
|
||||
transport='', # is set in backend
|
||||
assisted_listening_stream=assisted_listening,
|
||||
immediate_rendering=immediate_rendering,
|
||||
bigs = [
|
||||
auracast_config.AuracastBigConfig(
|
||||
code=(stream_passwort.strip() or None),
|
||||
name=stream_name,
|
||||
program_info=program_info,
|
||||
language=language,
|
||||
audio_source=(
|
||||
f"device:{input_device}" if audio_mode == "USB/Network" else (
|
||||
f"device:{input_device}" if audio_mode in ("USB", "AES67") else (
|
||||
"webrtc" if audio_mode == "Webapp" else "network"
|
||||
)
|
||||
),
|
||||
input_format=(f"int16le,{q['rate']},1" if audio_mode == "USB/Network" else "auto"),
|
||||
input_format=(f"int16le,{q['rate']},1" if audio_mode in ("USB", "AES67") else "auto"),
|
||||
iso_que_len=1,
|
||||
sampling_frequency=q['rate'],
|
||||
octets_per_frame=q['octets'],
|
||||
@@ -320,6 +474,7 @@ else:
|
||||
r = requests.post(f"{BACKEND_URL}/init", json=config.model_dump())
|
||||
if r.status_code == 200:
|
||||
st.success("Stream Started!")
|
||||
st.rerun()
|
||||
else:
|
||||
st.error(f"Failed to initialize: {r.text}")
|
||||
except Exception as e:
|
||||
@@ -393,6 +548,35 @@ else:
|
||||
# else:
|
||||
# st.error("Could not fetch advertised streams.")
|
||||
|
||||
############################
|
||||
# System expander (collapsed)
|
||||
############################
|
||||
with st.expander("System", expanded=False):
|
||||
if is_pw_disabled():
|
||||
st.info("Frontend password protection is disabled via DISABLE_FRONTEND_PW.")
|
||||
else:
|
||||
st.subheader("Change password")
|
||||
with st.form("change_pw_form"):
|
||||
cur = st.text_input("Current password", type="password")
|
||||
new1 = st.text_input("New password", type="password")
|
||||
new2 = st.text_input("Confirm new password", type="password")
|
||||
submit_change = st.form_submit_button("Change password")
|
||||
if submit_change:
|
||||
rec = load_pw_record()
|
||||
if not rec or not verify_password(cur, rec):
|
||||
st.error("Current password is incorrect.")
|
||||
elif len(new1) < 6:
|
||||
st.error("New password should be at least 6 characters.")
|
||||
elif new1 != new2:
|
||||
st.error("New passwords do not match.")
|
||||
else:
|
||||
salt, key = hash_password(new1)
|
||||
try:
|
||||
save_pw_record(salt, key)
|
||||
st.success("Password updated.")
|
||||
except Exception as e:
|
||||
st.error(f"Failed to update password: {e}")
|
||||
|
||||
log.basicConfig(
|
||||
level=os.environ.get('LOG_LEVEL', log.DEBUG),
|
||||
format='%(module)s.py:%(lineno)d %(levelname)s: %(message)s'
|
||||
|
||||
@@ -7,6 +7,8 @@ import sys
|
||||
from datetime import datetime
|
||||
import asyncio
|
||||
import numpy as np
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from pydantic import BaseModel
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
@@ -15,29 +17,42 @@ from aiortc import RTCPeerConnection, RTCSessionDescription, MediaStreamTrack
|
||||
import av
|
||||
import av.audio.layout
|
||||
import sounddevice as sd # type: ignore
|
||||
from typing import Set, List, Dict, Any
|
||||
from typing import Set
|
||||
import traceback
|
||||
from auracast.utils.sounddevice_utils import (
|
||||
list_usb_pw_inputs,
|
||||
list_network_pw_inputs,
|
||||
)
|
||||
|
||||
load_dotenv()
|
||||
# make sure pipewire sets latency
|
||||
STREAM_SETTINGS_FILE = os.path.join(os.path.dirname(__file__), 'stream_settings.json')
|
||||
# Raspberry Pi UART transports
|
||||
TRANSPORT1 = os.getenv('TRANSPORT1', 'serial:/dev/ttyAMA3,1000000,rtscts') # transport for raspberry pi gpio header
|
||||
TRANSPORT2 = os.getenv('TRANSPORT2', 'serial:/dev/ttyAMA4,1000000,rtscts') # transport for raspberry pi gpio header
|
||||
|
||||
PTIME = 40 # TODO: seems to have no effect at all
|
||||
PTIME = 40 # seems to have no effect at all
|
||||
pcs: Set[RTCPeerConnection] = set() # keep refs so they don’t GC early
|
||||
AUDIO_INPUT_DEVICES_CACHE: List[Dict[str, Any]] = []
|
||||
|
||||
class Offer(BaseModel):
|
||||
sdp: str
|
||||
type: str
|
||||
|
||||
def get_device_index_by_name(name: str):
|
||||
"""Return the device index for a given device name, or None if not found."""
|
||||
for d in AUDIO_INPUT_DEVICES_CACHE:
|
||||
if d["name"] == name:
|
||||
return d["id"]
|
||||
"""Return the device index for a given device name, or None if not found.
|
||||
|
||||
Queries the current sounddevice list directly (no cache).
|
||||
"""
|
||||
try:
|
||||
devs = sd.query_devices()
|
||||
for idx, d in enumerate(devs):
|
||||
if d.get("name") == name and d.get("max_input_channels", 0) > 0:
|
||||
return idx
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
# Path to persist stream settings
|
||||
STREAM_SETTINGS_FILE = os.path.join(os.path.dirname(__file__), 'stream_settings.json')
|
||||
|
||||
def load_stream_settings() -> dict:
|
||||
"""Load persisted stream settings if available."""
|
||||
if os.path.exists(STREAM_SETTINGS_FILE):
|
||||
@@ -81,21 +96,25 @@ async def initialize(conf: auracast_config.AuracastConfigGroup):
|
||||
global global_config_group
|
||||
global multicaster1
|
||||
try:
|
||||
if conf.transport == 'auto':
|
||||
serial_devices = glob.glob('/dev/serial/by-id/*')
|
||||
log.info('Found serial devices: %s', serial_devices)
|
||||
for device in serial_devices:
|
||||
if 'usb-ZEPHYR_Zephyr_HCI_UART_sample' in device:
|
||||
log.info('Using: %s', device)
|
||||
conf.transport = f'serial:{device},115200,rtscts'
|
||||
break
|
||||
if conf.transport == 'auto':
|
||||
raise HTTPException(status_code=500, detail='No suitable transport found.')
|
||||
|
||||
conf.transport = TRANSPORT1
|
||||
# Derive audio_mode and input_device from first BIG audio_source
|
||||
first_source = conf.bigs[0].audio_source if conf.bigs else ''
|
||||
if first_source.startswith('device:'):
|
||||
audio_mode_persist = 'USB'
|
||||
input_device_name = first_source.split(':', 1)[1] if ':' in first_source else None
|
||||
# Determine if the device is a USB or Network(AES67) PipeWire input
|
||||
try:
|
||||
usb_names = {d.get('name') for _, d in list_usb_pw_inputs(refresh=False)}
|
||||
net_names = {d.get('name') for _, d in list_network_pw_inputs(refresh=False)}
|
||||
except Exception:
|
||||
usb_names, net_names = set(), set()
|
||||
if input_device_name in net_names:
|
||||
audio_mode_persist = 'AES67'
|
||||
os.environ.setdefault("PULSE_LATENCY_MSEC", "6")
|
||||
else:
|
||||
audio_mode_persist = 'USB'
|
||||
os.environ.setdefault("PULSE_LATENCY_MSEC", "3")
|
||||
|
||||
# Map device name to current index for use with sounddevice
|
||||
device_index = get_device_index_by_name(input_device_name) if input_device_name else None
|
||||
# Patch config to use index for sounddevice (but persist name)
|
||||
@@ -122,6 +141,11 @@ async def initialize(conf: auracast_config.AuracastConfigGroup):
|
||||
'input_device': input_device_name,
|
||||
'program_info': [getattr(big, 'program_info', None) for big in conf.bigs],
|
||||
'gain': [getattr(big, 'input_gain', 1.0) for big in conf.bigs],
|
||||
'auracast_sampling_rate_hz': conf.auracast_sampling_rate_hz,
|
||||
'octets_per_frame': conf.octets_per_frame,
|
||||
'immediate_rendering': getattr(conf, 'immediate_rendering', False),
|
||||
'assisted_listening_stream': getattr(conf, 'assisted_listening_stream', False),
|
||||
'stream_password': (conf.bigs[0].code if conf.bigs and getattr(conf.bigs[0], 'code', None) else None),
|
||||
'timestamp': datetime.utcnow().isoformat()
|
||||
})
|
||||
global_config_group = conf
|
||||
@@ -145,16 +169,7 @@ async def initialize2(conf: auracast_config.AuracastConfigGroup):
|
||||
"""Initializes the secondary broadcaster (multicaster2). Does NOT persist stream settings."""
|
||||
global multicaster2
|
||||
try:
|
||||
if conf.transport == 'auto':
|
||||
serial_devices = glob.glob('/dev/serial/by-id/*')
|
||||
log.info('Found serial devices: %s', serial_devices)
|
||||
for device in serial_devices:
|
||||
if 'usb-ZEPHYR_Zephyr_HCI_UART_sample' in device:
|
||||
log.info('Using: %s', device)
|
||||
conf.transport = f'serial:{device},115200,rtscts'
|
||||
break
|
||||
if conf.transport == 'auto':
|
||||
raise HTTPException(status_code=500, detail='No suitable transport found.')
|
||||
conf.transport = TRANSPORT2
|
||||
# Patch device name to index for sounddevice
|
||||
for big in conf.bigs:
|
||||
if big.audio_source.startswith('device:'):
|
||||
@@ -232,47 +247,183 @@ async def get_status():
|
||||
return status
|
||||
|
||||
|
||||
async def scan_audio_devices():
|
||||
"""Scans for available audio devices and updates the cache."""
|
||||
global AUDIO_INPUT_DEVICES_CACHE
|
||||
log.info("Scanning for audio input devices...")
|
||||
async def _autostart_from_settings():
|
||||
"""Background task: auto-start last selected device-based input at server startup.
|
||||
|
||||
Skips Webapp (webrtc) and Demo (file) modes. Polls every 2 seconds until the
|
||||
saved device name appears in either USB or Network lists, then builds a config
|
||||
and initializes streaming.
|
||||
"""
|
||||
try:
|
||||
if sys.platform == 'linux':
|
||||
log.info("Re-initializing sounddevice to scan for new devices")
|
||||
sd._terminate()
|
||||
sd._initialize()
|
||||
|
||||
devs = sd.query_devices()
|
||||
inputs = [
|
||||
dict(d, id=idx)
|
||||
for idx, d in enumerate(devs)
|
||||
if d.get("max_input_channels", 0) > 0
|
||||
]
|
||||
log.info('Found %d audio input devices: %s', len(inputs), inputs)
|
||||
AUDIO_INPUT_DEVICES_CACHE = inputs
|
||||
settings = load_stream_settings() or {}
|
||||
audio_mode = settings.get('audio_mode')
|
||||
input_device_name = settings.get('input_device')
|
||||
rate = settings.get('auracast_sampling_rate_hz')
|
||||
octets = settings.get('octets_per_frame')
|
||||
immediate_rendering = settings.get('immediate_rendering', False)
|
||||
assisted_listening_stream = settings.get('assisted_listening_stream', False)
|
||||
channel_names = settings.get('channel_names') or ["Broadcast0"]
|
||||
program_info = settings.get('program_info') or channel_names
|
||||
languages = settings.get('languages') or ["deu"]
|
||||
stream_password = settings.get('stream_password')
|
||||
original_ts = settings.get('timestamp')
|
||||
|
||||
try:
|
||||
usb_names = {d.get('name') for _, d in list_usb_pw_inputs(refresh=False)}
|
||||
net_names = {d.get('name') for _, d in list_network_pw_inputs(refresh=False)}
|
||||
except Exception:
|
||||
usb_names, net_names = set(), set()
|
||||
if input_device_name in net_names:
|
||||
os.environ.setdefault("PULSE_LATENCY_MSEC", "6")
|
||||
else:
|
||||
os.environ.setdefault("PULSE_LATENCY_MSEC", "3")
|
||||
|
||||
# Only auto-start device-based inputs; Webapp and Demo require external sources/UI
|
||||
if not input_device_name:
|
||||
return
|
||||
if rate is None or octets is None:
|
||||
# Not enough info to reconstruct stream reliably
|
||||
return
|
||||
|
||||
# Avoid duplicate start if already streaming
|
||||
if multicaster1 and multicaster1.get_status().get('is_streaming'):
|
||||
return
|
||||
|
||||
while True:
|
||||
# Do not interfere if user started a stream manually in the meantime
|
||||
if multicaster1 and multicaster1.get_status().get('is_streaming'):
|
||||
return
|
||||
# Abort if saved settings changed to a different target while we were polling
|
||||
current_settings = load_stream_settings() or {}
|
||||
if current_settings.get('timestamp') != original_ts:
|
||||
# Settings were updated (likely by user via /init)
|
||||
# If the target device or mode changed, stop autostart
|
||||
if (
|
||||
current_settings.get('input_device') != input_device_name or
|
||||
current_settings.get('audio_mode') != audio_mode
|
||||
):
|
||||
return
|
||||
# Avoid refreshing PortAudio while we poll
|
||||
usb = [d for _, d in list_usb_pw_inputs(refresh=False)]
|
||||
net = [d for _, d in list_network_pw_inputs(refresh=False)]
|
||||
names = {d.get('name') for d in usb} | {d.get('name') for d in net}
|
||||
if input_device_name in names:
|
||||
# Build a minimal config based on saved fields
|
||||
bigs = [
|
||||
auracast_config.AuracastBigConfig(
|
||||
code=stream_password,
|
||||
name=channel_names[0] if channel_names else "Broadcast0",
|
||||
program_info=program_info[0] if isinstance(program_info, list) and program_info else program_info,
|
||||
language=languages[0] if languages else "deu",
|
||||
audio_source=f"device:{input_device_name}",
|
||||
input_format=f"int16le,{rate},1",
|
||||
iso_que_len=1,
|
||||
sampling_frequency=rate,
|
||||
octets_per_frame=octets,
|
||||
)
|
||||
]
|
||||
conf = auracast_config.AuracastConfigGroup(
|
||||
auracast_sampling_rate_hz=rate,
|
||||
octets_per_frame=octets,
|
||||
transport=TRANSPORT1,
|
||||
immediate_rendering=immediate_rendering,
|
||||
assisted_listening_stream=assisted_listening_stream,
|
||||
bigs=bigs,
|
||||
)
|
||||
# Initialize and start
|
||||
await initialize(conf)
|
||||
return
|
||||
await asyncio.sleep(2)
|
||||
except Exception:
|
||||
log.error("Exception while scanning audio devices:", exc_info=True)
|
||||
# Do not clear cache on error, keep the last known good list
|
||||
log.warning("Autostart task failed", exc_info=True)
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup_event():
|
||||
"""Pre-scans audio devices on startup."""
|
||||
await scan_audio_devices()
|
||||
|
||||
|
||||
@app.get("/audio_inputs")
|
||||
async def list_audio_inputs():
|
||||
"""Return available hardware audio input devices from cache (by name, for selection)."""
|
||||
# Only expose name and id for frontend
|
||||
return {"inputs": AUDIO_INPUT_DEVICES_CACHE}
|
||||
#TODO: enable and test this
|
||||
@app.on_event("startup")
|
||||
async def _startup_autostart_event():
|
||||
# Spawn the autostart task without blocking startup
|
||||
asyncio.create_task(_autostart_from_settings())
|
||||
|
||||
|
||||
@app.post("/refresh_audio_inputs")
|
||||
async def refresh_audio_inputs():
|
||||
"""Triggers a re-scan of audio devices."""
|
||||
await scan_audio_devices()
|
||||
return {"status": "ok", "inputs": AUDIO_INPUT_DEVICES_CACHE}
|
||||
async def refresh_audio_inputs(force: bool = False):
|
||||
"""Triggers a re-scan of audio devices.
|
||||
|
||||
If force is True and a stream is active, the stream(s) will be stopped to allow
|
||||
a full re-initialization of the sounddevice backend. The response will include
|
||||
'stopped_stream': True if any running stream was stopped.
|
||||
"""
|
||||
stopped = False
|
||||
if force:
|
||||
try:
|
||||
# Stop active streams before forcing sounddevice re-init
|
||||
if multicaster1 is not None and multicaster1.get_status().get('is_streaming'):
|
||||
await multicaster1.stop_streaming()
|
||||
stopped = True
|
||||
if multicaster2 is not None and multicaster2.get_status().get('is_streaming'):
|
||||
await multicaster2.stop_streaming()
|
||||
stopped = True
|
||||
except Exception:
|
||||
log.warning("Failed to stop stream(s) before force refresh", exc_info=True)
|
||||
# Reinitialize sounddevice backend if requested
|
||||
try:
|
||||
if sys.platform == 'linux' and force:
|
||||
log.info("Force re-initializing sounddevice backend")
|
||||
sd._terminate()
|
||||
sd._initialize()
|
||||
except Exception:
|
||||
log.error("Exception while force-refreshing audio devices:", exc_info=True)
|
||||
return {"status": "ok", "inputs": [], "stopped_stream": stopped}
|
||||
|
||||
|
||||
@app.get("/audio_inputs_pw_usb")
|
||||
async def audio_inputs_pw_usb():
|
||||
"""List PipeWire USB input nodes mapped to sounddevice indices.
|
||||
|
||||
Returns a list of dicts: [{id, name, max_input_channels}].
|
||||
"""
|
||||
try:
|
||||
# Do not refresh PortAudio if we are currently streaming to avoid termination
|
||||
streaming = False
|
||||
try:
|
||||
if multicaster1 is not None:
|
||||
status = multicaster1.get_status()
|
||||
streaming = bool(status.get('is_streaming'))
|
||||
except Exception:
|
||||
streaming = False
|
||||
devices = [
|
||||
{"id": idx, "name": dev.get("name"), "max_input_channels": dev.get("max_input_channels", 0)}
|
||||
for idx, dev in list_usb_pw_inputs(refresh=not streaming)
|
||||
]
|
||||
return {"inputs": devices}
|
||||
except Exception as e:
|
||||
log.error("Exception in /audio_inputs_pw_usb: %s", traceback.format_exc())
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@app.get("/audio_inputs_pw_network")
|
||||
async def audio_inputs_pw_network():
|
||||
"""List PipeWire Network/AES67 input nodes mapped to sounddevice indices.
|
||||
|
||||
Returns a list of dicts: [{id, name, max_input_channels}].
|
||||
"""
|
||||
try:
|
||||
# Do not refresh PortAudio if we are currently streaming to avoid termination
|
||||
streaming = False
|
||||
try:
|
||||
if multicaster1 is not None:
|
||||
status = multicaster1.get_status()
|
||||
streaming = bool(status.get('is_streaming'))
|
||||
except Exception:
|
||||
streaming = False
|
||||
devices = [
|
||||
{"id": idx, "name": dev.get("name"), "max_input_channels": dev.get("max_input_channels", 0)}
|
||||
for idx, dev in list_network_pw_inputs(refresh=not streaming)
|
||||
]
|
||||
return {"inputs": devices}
|
||||
except Exception as e:
|
||||
log.error("Exception in /audio_inputs_pw_network: %s", traceback.format_exc())
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@app.post("/offer")
|
||||
|
||||
BIN
src/auracast/utils/favicon.ico
Normal file
BIN
src/auracast/utils/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
96
src/auracast/utils/frontend_auth.py
Normal file
96
src/auracast/utils/frontend_auth.py
Normal 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
|
||||
@@ -42,17 +42,25 @@ def _sd_matches_from_names(pa_idx, names):
|
||||
if d["hostapi"] != pa_idx or d["max_input_channels"] <= 0:
|
||||
continue
|
||||
dn = d["name"].lower()
|
||||
# Exclude monitor devices (e.g., "Monitor of ...") to avoid false positives
|
||||
if "monitor" in dn:
|
||||
continue
|
||||
if any(n in dn for n in names_l):
|
||||
out.append((i, d))
|
||||
return out
|
||||
|
||||
def list_usb_pw_inputs():
|
||||
def list_usb_pw_inputs(refresh: bool = True):
|
||||
"""
|
||||
Return [(device_index, device_dict), ...] for PipeWire **input** nodes
|
||||
backed by **USB** devices (excludes monitor sources).
|
||||
|
||||
Parameters:
|
||||
- refresh (bool): If True (default), force PortAudio to re-enumerate devices
|
||||
before mapping. Set to False to avoid disrupting active streams.
|
||||
"""
|
||||
# Refresh PortAudio so we see newly added nodes before mapping
|
||||
_sd_refresh()
|
||||
if refresh:
|
||||
_sd_refresh()
|
||||
pa_idx = _pa_like_hostapi_index()
|
||||
pw = _pw_dump()
|
||||
|
||||
@@ -84,13 +92,18 @@ def list_usb_pw_inputs():
|
||||
# Map to sounddevice devices on PipeWire host API
|
||||
return _sd_matches_from_names(pa_idx, usb_input_names)
|
||||
|
||||
def list_network_pw_inputs():
|
||||
def list_network_pw_inputs(refresh: bool = True):
|
||||
"""
|
||||
Return [(device_index, device_dict), ...] for PipeWire **input** nodes that
|
||||
look like network/AES67/RTP sources (excludes monitor sources).
|
||||
|
||||
Parameters:
|
||||
- refresh (bool): If True (default), force PortAudio to re-enumerate devices
|
||||
before mapping. Set to False to avoid disrupting active streams.
|
||||
"""
|
||||
# Refresh PortAudio so we see newly added nodes before mapping
|
||||
_sd_refresh()
|
||||
if refresh:
|
||||
_sd_refresh()
|
||||
pa_idx = _pa_like_hostapi_index()
|
||||
pw = _pw_dump()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user