From e80ff79d67f871846c327d73326b25551970b623 Mon Sep 17 00:00:00 2001 From: pstruebi Date: Mon, 8 Sep 2025 14:02:36 +0200 Subject: [PATCH 01/20] feat: split USB/Network mode into separate USB and AES67 input options with dedicated device lists --- src/auracast/server/multicast_frontend.py | 57 +++++++--- src/auracast/server/multicast_server.py | 126 +++++++++++++++------- src/auracast/utils/sounddevice_utils.py | 21 +++- 3 files changed, 147 insertions(+), 57 deletions(-) diff --git a/src/auracast/server/multicast_frontend.py b/src/auracast/server/multicast_frontend.py index 2d383f7..46e7d56 100644 --- a/src/auracast/server/multicast_frontend.py +++ b/src/auracast/server/multicast_frontend.py @@ -37,16 +37,23 @@ except Exception: st.title("🎙️ Auracast Audio Mode Control") # Audio mode selection with persisted default -options = ["Webapp", "USB/Network", "Demo"] +# Note: backend persists 'USB' for any device: source (including AES67). We default to 'USB' in that case. +options = ["Webapp", "USB", "AES67", "Demo"] saved_audio_mode = saved_settings.get("audio_mode", "Webapp") 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, "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." + index=options.index(saved_audio_mode) if saved_audio_mode in options else options.index("Webapp"), + 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": @@ -200,16 +207,22 @@ else: 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 +232,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. Ensure AES67 sources are visible in PipeWire and click Refresh." + ) + 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,12 +261,16 @@ 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 @@ -304,11 +331,11 @@ else: 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'], diff --git a/src/auracast/server/multicast_server.py b/src/auracast/server/multicast_server.py index 7e4e588..d17916a 100644 --- a/src/auracast/server/multicast_server.py +++ b/src/auracast/server/multicast_server.py @@ -15,23 +15,33 @@ 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, +) PTIME = 40 # TODO: 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 @@ -232,47 +242,87 @@ 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...") +@app.post("/refresh_audio_inputs") +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': - log.info("Re-initializing sounddevice to scan for new devices") + if sys.platform == 'linux' and force: + log.info("Force re-initializing sounddevice backend") 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 + log.error("Exception while force-refreshing audio devices:", exc_info=True) + return {"status": "ok", "inputs": [], "stopped_stream": stopped} + + -@app.on_event("startup") -async def startup_event(): - """Pre-scans audio devices on startup.""" - await scan_audio_devices() +@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") -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.get("/audio_inputs_pw_network") +async def audio_inputs_pw_network(): + """List PipeWire Network/AES67 input nodes mapped to sounddevice indices. - -@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} + 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") diff --git a/src/auracast/utils/sounddevice_utils.py b/src/auracast/utils/sounddevice_utils.py index f1f4f05..35dbea0 100644 --- a/src/auracast/utils/sounddevice_utils.py +++ b/src/auracast/utils/sounddevice_utils.py @@ -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() -- 2.52.0 From fe3ed1636db308ebc1b17e641332d5be99494b40 Mon Sep 17 00:00:00 2001 From: pstruebi Date: Mon, 8 Sep 2025 14:29:33 +0200 Subject: [PATCH 02/20] feat: add auto-start functionality for last used audio device on server startup --- src/auracast/server/multicast_server.py | 118 +++++++++++++++++++----- 1 file changed, 97 insertions(+), 21 deletions(-) diff --git a/src/auracast/server/multicast_server.py b/src/auracast/server/multicast_server.py index d17916a..977d74c 100644 --- a/src/auracast/server/multicast_server.py +++ b/src/auracast/server/multicast_server.py @@ -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 @@ -21,7 +23,7 @@ from auracast.utils.sounddevice_utils import ( list_usb_pw_inputs, list_network_pw_inputs, ) - +load_dotenv() PTIME = 40 # TODO: seems to have no effect at all pcs: Set[RTCPeerConnection] = set() # keep refs so they don’t GC early @@ -85,22 +87,19 @@ global_config_group = auracast_config.AuracastConfigGroup() multicaster1: multicast_control.Multicaster | None = None multicaster2: multicast_control.Multicaster | None = None + +# 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 + @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.') + + 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:'): @@ -132,6 +131,8 @@ 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, 'timestamp': datetime.utcnow().isoformat() }) global_config_group = conf @@ -155,16 +156,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:'): @@ -242,6 +234,90 @@ async def get_status(): return status +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: + 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') + channel_names = settings.get('channel_names') or ["Broadcast0"] + program_info = settings.get('program_info') or channel_names + languages = settings.get('languages') or ["deu"] + original_ts = settings.get('timestamp') + + # 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: + try: + # 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( + 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, + bigs=bigs, + ) + # Initialize and start + await initialize(conf) + return + except Exception: + log.warning("Autostart polling encountered an error", exc_info=True) + await asyncio.sleep(2) + except Exception: + log.warning("Autostart task failed", exc_info=True) + + +@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(force: bool = False): """Triggers a re-scan of audio devices. -- 2.52.0 From 90633d1f1aa16330bf871e0cf945ab1e3e6d00a9 Mon Sep 17 00:00:00 2001 From: pstruebi Date: Mon, 8 Sep 2025 14:42:28 +0200 Subject: [PATCH 03/20] fix: improve pipewire-aes67 service reliability with network dependency and restart handling --- src/service/pipewire-aes67.service | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/service/pipewire-aes67.service b/src/service/pipewire-aes67.service index b800101..d3eeb30 100644 --- a/src/service/pipewire-aes67.service +++ b/src/service/pipewire-aes67.service @@ -1,11 +1,16 @@ [Unit] Description=PipeWire AES67 Service -After=network.target +After=default.target network-online.target +Wants=network-online.target [Service] Type=simple -ExecStart=/usr/bin/pipewire-aes67 -c /home/caster/bumble-auracast/src/service/aes67/pipewire-aes67.conf -Restart=on-failure +ExecStartPre=/bin/sh -lc 'for i in $(seq 1 60); do ip route show default >/dev/null 2>&1 && break; sleep 2; done' +ExecStart=/usr/bin/pipewire-aes67 -c /home/caster/bumble-auracast/src/service/aes67/pipewire-aes67.conf +Restart=always +RestartSec=5s +# Avoid StartLimitHit on quick failures during boot; let RestartSec handle pacing +StartLimitIntervalSec=0 [Install] WantedBy=default.target -- 2.52.0 From 6b2158d48223be84becc33187730dbc45ae37628 Mon Sep 17 00:00:00 2001 From: pstruebi Date: Mon, 8 Sep 2025 14:47:17 +0200 Subject: [PATCH 04/20] improve pw-aes67 start script --- src/service/update_and_run_pw_aes67.sh | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/src/service/update_and_run_pw_aes67.sh b/src/service/update_and_run_pw_aes67.sh index 30da008..b3e02e8 100644 --- a/src/service/update_and_run_pw_aes67.sh +++ b/src/service/update_and_run_pw_aes67.sh @@ -25,16 +25,12 @@ 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 +sudo systemctl restart ptp_aes67.service -echo "\n--- pipewire.service status (user) ---" -systemctl --user status pipewire.service --no-pager - -echo "\n--- ptp_aes67.service status ---" +# print status sudo systemctl status ptp_aes67.service --no-pager - -echo "\n--- pipewire-aes67.service status (user) ---" +systemctl --user status pipewire.service --no-pager systemctl --user status pipewire-aes67.service --no-pager -- 2.52.0 From c57462b6b5aa1103b60c24f564e41c8078029195 Mon Sep 17 00:00:00 2001 From: pstruebi Date: Mon, 8 Sep 2025 15:03:36 +0200 Subject: [PATCH 05/20] feat: add pipewire latency config and reorder variable declarations in multicast server --- src/auracast/server/multicast_server.py | 19 +++++++++---------- .../update_and_run_server_and_frontend.sh | 5 +---- 2 files changed, 10 insertions(+), 14 deletions(-) diff --git a/src/auracast/server/multicast_server.py b/src/auracast/server/multicast_server.py index 977d74c..8f0e960 100644 --- a/src/auracast/server/multicast_server.py +++ b/src/auracast/server/multicast_server.py @@ -23,9 +23,16 @@ from auracast.utils.sounddevice_utils import ( list_usb_pw_inputs, list_network_pw_inputs, ) -load_dotenv() -PTIME = 40 # TODO: seems to have no effect at all +load_dotenv() +# make sure pipewire sets latency +os.environ.setdefault("PULSE_LATENCY_MSEC", "3") +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 # seems to have no effect at all pcs: Set[RTCPeerConnection] = set() # keep refs so they don’t GC early class Offer(BaseModel): @@ -47,9 +54,6 @@ def get_device_index_by_name(name: str): 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): @@ -87,11 +91,6 @@ global_config_group = auracast_config.AuracastConfigGroup() multicaster1: multicast_control.Multicaster | None = None multicaster2: multicast_control.Multicaster | None = None - -# 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 - @app.post("/init") async def initialize(conf: auracast_config.AuracastConfigGroup): """Initializes the primary broadcaster (multicaster1).""" diff --git a/src/service/update_and_run_server_and_frontend.sh b/src/service/update_and_run_server_and_frontend.sh index 5130bb2..fc93513 100644 --- a/src/service/update_and_run_server_and_frontend.sh +++ b/src/service/update_and_run_server_and_frontend.sh @@ -25,9 +25,6 @@ systemctl --user enable auracast-server.service sudo systemctl restart auracast-frontend.service systemctl --user restart auracast-server.service -echo "\n--- auracast-frontend.service status ---" +#print 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." -- 2.52.0 From 5d5538165725057992e407008fbfa50374701133 Mon Sep 17 00:00:00 2001 From: pstruebi Date: Tue, 16 Sep 2025 15:27:55 +0200 Subject: [PATCH 06/20] fix: increase AES67 audio latency to 12ms to reduce audio errors --- README.md | 3 +++ src/service/aes67/pipewire-aes67.conf | 4 ++-- src/service/update_and_run_pw_aes67.sh | 2 +- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 7795ec3..ccb0b8c 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,9 @@ sudo ./provision_domain_hostname.sh - 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. +# Audio latency +if there is hearable audio error with aes67, tune sess.latency.msec in pipewire-aes67.conf + --- After completing these steps, your device will be discoverable as `.` (e.g., `box1.auracast.local`) on the local network via mDNS. diff --git a/src/service/aes67/pipewire-aes67.conf b/src/service/aes67/pipewire-aes67.conf index 001c24a..812a56e 100644 --- a/src/service/aes67/pipewire-aes67.conf +++ b/src/service/aes67/pipewire-aes67.conf @@ -92,8 +92,8 @@ context.modules = [ 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" + sess.latency.msec = 12 + #node.latency = "192/48000" node.group = pipewire.ptp0 } } diff --git a/src/service/update_and_run_pw_aes67.sh b/src/service/update_and_run_pw_aes67.sh index b3e02e8..2ddbe0c 100644 --- a/src/service/update_and_run_pw_aes67.sh +++ b/src/service/update_and_run_pw_aes67.sh @@ -11,7 +11,7 @@ sudo cp /home/caster/bumble-auracast/src/service/ptp_aes67.service /etc/systemd/ 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) +# Install PipeWire user config to persist 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 -- 2.52.0 From d681546ac546a6f1fb77a98209cce37e0597e04e Mon Sep 17 00:00:00 2001 From: pstruebi Date: Fri, 19 Sep 2025 13:11:17 +0200 Subject: [PATCH 07/20] implement support for coded streams --- poetry.lock | 108 +++++++++++++++++++------------------- pyproject.toml | 4 +- src/auracast/multicast.py | 24 +++++++-- 3 files changed, 78 insertions(+), 58 deletions(-) diff --git a/poetry.lock b/poetry.lock index ef35d0e..70213c8 100644 --- a/poetry.lock +++ b/poetry.lock @@ -332,10 +332,10 @@ files = [ [[package]] name = "bumble" -version = "0.0.209.dev2+g12bcdb7" +version = "0.0.216.dev1+g6eba81e3d" description = "Bluetooth Stack for Apps, Emulation, Test and Experimentation" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" groups = ["main"] files = [] develop = false @@ -344,7 +344,7 @@ develop = false aiohttp = {version = ">=3.8,<4.0", markers = "platform_system != \"Emscripten\""} appdirs = {version = ">=1.4", markers = "platform_system != \"Emscripten\""} click = {version = ">=8.1.3", markers = "platform_system != \"Emscripten\""} -cryptography = ">=39" +cryptography = ">=44.0.3" grpcio = {version = ">=1.62.1", markers = "platform_system != \"Emscripten\""} humanize = {version = ">=4.6.0", markers = "platform_system != \"Emscripten\""} libusb-package = {version = "1.0.26.1", markers = "platform_system != \"Emscripten\""} @@ -353,7 +353,7 @@ platformdirs = {version = ">=3.10.0", markers = "platform_system != \"Emscripten prettytable = {version = ">=3.6.0", markers = "platform_system != \"Emscripten\""} prompt_toolkit = {version = ">=3.0.16", markers = "platform_system != \"Emscripten\""} protobuf = {version = ">=3.12.4", markers = "platform_system != \"Emscripten\""} -pyee = ">=8.2.2" +pyee = ">=13.0.0" pyserial = {version = ">=3.5", markers = "platform_system != \"Emscripten\""} pyserial-asyncio = {version = ">=0.5", markers = "platform_system != \"Emscripten\""} pyusb = {version = ">=1.2", markers = "platform_system != \"Emscripten\""} @@ -363,7 +363,7 @@ websockets = {version = "13.1", markers = "platform_system != \"Emscripten\""} auracast = ["lc3py (>=1.1.3) ; python_version >= \"3.10\" and (platform_system == \"Linux\" and platform_machine == \"x86_64\" or platform_system == \"Darwin\" and platform_machine == \"arm64\")", "sounddevice (>=0.5.1)"] avatar = ["pandora-avatar (==0.0.10)", "rootcanal (==1.11.1) ; python_version >= \"3.10\""] build = ["build (>=0.7)"] -development = ["black (==24.3)", "bt-test-interfaces (>=0.0.6)", "grpcio-tools (>=1.62.1)", "invoke (>=1.7.3)", "mobly (>=1.12.2)", "mypy (==1.12.0)", "nox (>=2022)", "pylint (==3.3.1)", "pyyaml (>=6.0)", "types-appdirs (>=1.4.3)", "types-invoke (>=1.7.3)", "types-protobuf (>=4.21.0)"] +development = ["black (>=25.1,<26.0)", "bt-test-interfaces (>=0.0.6)", "grpcio-tools (>=1.62.1)", "invoke (>=1.7.3)", "isort (>=5.13.2,<5.14.0)", "mobly (>=1.12.2)", "mypy (==1.12.0)", "nox (>=2022)", "pylint (==3.3.1)", "pyyaml (>=6.0)", "types-appdirs (>=1.4.3)", "types-invoke (>=1.7.3)", "types-protobuf (>=4.21.0)"] documentation = ["mkdocs (>=1.6.0)", "mkdocs-material (>=9.6)", "mkdocstrings[python] (>=0.27.0)"] pandora = ["bt-test-interfaces (>=0.0.6)"] test = ["coverage (>=6.4)", "pytest (>=8.2)", "pytest-asyncio (>=0.23.5)", "pytest-html (>=3.2.0)"] @@ -371,8 +371,8 @@ test = ["coverage (>=6.4)", "pytest (>=8.2)", "pytest-asyncio (>=0.23.5)", "pyte [package.source] type = "git" url = "ssh://git@ssh.pstruebi.xyz:222/auracaster/bumble_mirror.git" -reference = "12bcdb7770c0d57a094bc0a96cd52e701f97fece" -resolved_reference = "12bcdb7770c0d57a094bc0a96cd52e701f97fece" +reference = "6eba81e3ddb8ac0e4c336ca244892a0a8d43ba1c" +resolved_reference = "6eba81e3ddb8ac0e4c336ca244892a0a8d43ba1c" [[package]] name = "cachetools" @@ -610,60 +610,62 @@ files = [ [[package]] name = "cryptography" -version = "44.0.2" +version = "45.0.7" description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." optional = false python-versions = "!=3.9.0,!=3.9.1,>=3.7" groups = ["main"] files = [ - {file = "cryptography-44.0.2-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:efcfe97d1b3c79e486554efddeb8f6f53a4cdd4cf6086642784fa31fc384e1d7"}, - {file = "cryptography-44.0.2-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29ecec49f3ba3f3849362854b7253a9f59799e3763b0c9d0826259a88efa02f1"}, - {file = "cryptography-44.0.2-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc821e161ae88bfe8088d11bb39caf2916562e0a2dc7b6d56714a48b784ef0bb"}, - {file = "cryptography-44.0.2-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:3c00b6b757b32ce0f62c574b78b939afab9eecaf597c4d624caca4f9e71e7843"}, - {file = "cryptography-44.0.2-cp37-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7bdcd82189759aba3816d1f729ce42ffded1ac304c151d0a8e89b9996ab863d5"}, - {file = "cryptography-44.0.2-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:4973da6ca3db4405c54cd0b26d328be54c7747e89e284fcff166132eb7bccc9c"}, - {file = "cryptography-44.0.2-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:4e389622b6927d8133f314949a9812972711a111d577a5d1f4bee5e58736b80a"}, - {file = "cryptography-44.0.2-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:f514ef4cd14bb6fb484b4a60203e912cfcb64f2ab139e88c2274511514bf7308"}, - {file = "cryptography-44.0.2-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1bc312dfb7a6e5d66082c87c34c8a62176e684b6fe3d90fcfe1568de675e6688"}, - {file = "cryptography-44.0.2-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3b721b8b4d948b218c88cb8c45a01793483821e709afe5f622861fc6182b20a7"}, - {file = "cryptography-44.0.2-cp37-abi3-win32.whl", hash = "sha256:51e4de3af4ec3899d6d178a8c005226491c27c4ba84101bfb59c901e10ca9f79"}, - {file = "cryptography-44.0.2-cp37-abi3-win_amd64.whl", hash = "sha256:c505d61b6176aaf982c5717ce04e87da5abc9a36a5b39ac03905c4aafe8de7aa"}, - {file = "cryptography-44.0.2-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:8e0ddd63e6bf1161800592c71ac794d3fb8001f2caebe0966e77c5234fa9efc3"}, - {file = "cryptography-44.0.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:81276f0ea79a208d961c433a947029e1a15948966658cf6710bbabb60fcc2639"}, - {file = "cryptography-44.0.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9a1e657c0f4ea2a23304ee3f964db058c9e9e635cc7019c4aa21c330755ef6fd"}, - {file = "cryptography-44.0.2-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6210c05941994290f3f7f175a4a57dbbb2afd9273657614c506d5976db061181"}, - {file = "cryptography-44.0.2-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d1c3572526997b36f245a96a2b1713bf79ce99b271bbcf084beb6b9b075f29ea"}, - {file = "cryptography-44.0.2-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:b042d2a275c8cee83a4b7ae30c45a15e6a4baa65a179a0ec2d78ebb90e4f6699"}, - {file = "cryptography-44.0.2-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:d03806036b4f89e3b13b6218fefea8d5312e450935b1a2d55f0524e2ed7c59d9"}, - {file = "cryptography-44.0.2-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:c7362add18b416b69d58c910caa217f980c5ef39b23a38a0880dfd87bdf8cd23"}, - {file = "cryptography-44.0.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:8cadc6e3b5a1f144a039ea08a0bdb03a2a92e19c46be3285123d32029f40a922"}, - {file = "cryptography-44.0.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6f101b1f780f7fc613d040ca4bdf835c6ef3b00e9bd7125a4255ec574c7916e4"}, - {file = "cryptography-44.0.2-cp39-abi3-win32.whl", hash = "sha256:3dc62975e31617badc19a906481deacdeb80b4bb454394b4098e3f2525a488c5"}, - {file = "cryptography-44.0.2-cp39-abi3-win_amd64.whl", hash = "sha256:5f6f90b72d8ccadb9c6e311c775c8305381db88374c65fa1a68250aa8a9cb3a6"}, - {file = "cryptography-44.0.2-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:af4ff3e388f2fa7bff9f7f2b31b87d5651c45731d3e8cfa0944be43dff5cfbdb"}, - {file = "cryptography-44.0.2-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:0529b1d5a0105dd3731fa65680b45ce49da4d8115ea76e9da77a875396727b41"}, - {file = "cryptography-44.0.2-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:7ca25849404be2f8e4b3c59483d9d3c51298a22c1c61a0e84415104dacaf5562"}, - {file = "cryptography-44.0.2-pp310-pypy310_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:268e4e9b177c76d569e8a145a6939eca9a5fec658c932348598818acf31ae9a5"}, - {file = "cryptography-44.0.2-pp310-pypy310_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:9eb9d22b0a5d8fd9925a7764a054dca914000607dff201a24c791ff5c799e1fa"}, - {file = "cryptography-44.0.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:2bf7bf75f7df9715f810d1b038870309342bff3069c5bd8c6b96128cb158668d"}, - {file = "cryptography-44.0.2-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:909c97ab43a9c0c0b0ada7a1281430e4e5ec0458e6d9244c0e821bbf152f061d"}, - {file = "cryptography-44.0.2-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:96e7a5e9d6e71f9f4fca8eebfd603f8e86c5225bb18eb621b2c1e50b290a9471"}, - {file = "cryptography-44.0.2-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:d1b3031093a366ac767b3feb8bcddb596671b3aaff82d4050f984da0c248b615"}, - {file = "cryptography-44.0.2-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:04abd71114848aa25edb28e225ab5f268096f44cf0127f3d36975bdf1bdf3390"}, - {file = "cryptography-44.0.2.tar.gz", hash = "sha256:c63454aa261a0cf0c5b4718349629793e9e634993538db841165b3df74f37ec0"}, + {file = "cryptography-45.0.7-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:3be4f21c6245930688bd9e162829480de027f8bf962ede33d4f8ba7d67a00cee"}, + {file = "cryptography-45.0.7-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:67285f8a611b0ebc0857ced2081e30302909f571a46bfa7a3cc0ad303fe015c6"}, + {file = "cryptography-45.0.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:577470e39e60a6cd7780793202e63536026d9b8641de011ed9d8174da9ca5339"}, + {file = "cryptography-45.0.7-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4bd3e5c4b9682bc112d634f2c6ccc6736ed3635fc3319ac2bb11d768cc5a00d8"}, + {file = "cryptography-45.0.7-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:465ccac9d70115cd4de7186e60cfe989de73f7bb23e8a7aa45af18f7412e75bf"}, + {file = "cryptography-45.0.7-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:16ede8a4f7929b4b7ff3642eba2bf79aa1d71f24ab6ee443935c0d269b6bc513"}, + {file = "cryptography-45.0.7-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:8978132287a9d3ad6b54fcd1e08548033cc09dc6aacacb6c004c73c3eb5d3ac3"}, + {file = "cryptography-45.0.7-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:b6a0e535baec27b528cb07a119f321ac024592388c5681a5ced167ae98e9fff3"}, + {file = "cryptography-45.0.7-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:a24ee598d10befaec178efdff6054bc4d7e883f615bfbcd08126a0f4931c83a6"}, + {file = "cryptography-45.0.7-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:fa26fa54c0a9384c27fcdc905a2fb7d60ac6e47d14bc2692145f2b3b1e2cfdbd"}, + {file = "cryptography-45.0.7-cp311-abi3-win32.whl", hash = "sha256:bef32a5e327bd8e5af915d3416ffefdbe65ed975b646b3805be81b23580b57b8"}, + {file = "cryptography-45.0.7-cp311-abi3-win_amd64.whl", hash = "sha256:3808e6b2e5f0b46d981c24d79648e5c25c35e59902ea4391a0dcb3e667bf7443"}, + {file = "cryptography-45.0.7-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:bfb4c801f65dd61cedfc61a83732327fafbac55a47282e6f26f073ca7a41c3b2"}, + {file = "cryptography-45.0.7-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:81823935e2f8d476707e85a78a405953a03ef7b7b4f55f93f7c2d9680e5e0691"}, + {file = "cryptography-45.0.7-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3994c809c17fc570c2af12c9b840d7cea85a9fd3e5c0e0491f4fa3c029216d59"}, + {file = "cryptography-45.0.7-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:dad43797959a74103cb59c5dac71409f9c27d34c8a05921341fb64ea8ccb1dd4"}, + {file = "cryptography-45.0.7-cp37-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ce7a453385e4c4693985b4a4a3533e041558851eae061a58a5405363b098fcd3"}, + {file = "cryptography-45.0.7-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:b04f85ac3a90c227b6e5890acb0edbaf3140938dbecf07bff618bf3638578cf1"}, + {file = "cryptography-45.0.7-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:48c41a44ef8b8c2e80ca4527ee81daa4c527df3ecbc9423c41a420a9559d0e27"}, + {file = "cryptography-45.0.7-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:f3df7b3d0f91b88b2106031fd995802a2e9ae13e02c36c1fc075b43f420f3a17"}, + {file = "cryptography-45.0.7-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:dd342f085542f6eb894ca00ef70236ea46070c8a13824c6bde0dfdcd36065b9b"}, + {file = "cryptography-45.0.7-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1993a1bb7e4eccfb922b6cd414f072e08ff5816702a0bdb8941c247a6b1b287c"}, + {file = "cryptography-45.0.7-cp37-abi3-win32.whl", hash = "sha256:18fcf70f243fe07252dcb1b268a687f2358025ce32f9f88028ca5c364b123ef5"}, + {file = "cryptography-45.0.7-cp37-abi3-win_amd64.whl", hash = "sha256:7285a89df4900ed3bfaad5679b1e668cb4b38a8de1ccbfc84b05f34512da0a90"}, + {file = "cryptography-45.0.7-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:de58755d723e86175756f463f2f0bddd45cc36fbd62601228a3f8761c9f58252"}, + {file = "cryptography-45.0.7-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:a20e442e917889d1a6b3c570c9e3fa2fdc398c20868abcea268ea33c024c4083"}, + {file = "cryptography-45.0.7-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:258e0dff86d1d891169b5af222d362468a9570e2532923088658aa866eb11130"}, + {file = "cryptography-45.0.7-pp310-pypy310_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:d97cf502abe2ab9eff8bd5e4aca274da8d06dd3ef08b759a8d6143f4ad65d4b4"}, + {file = "cryptography-45.0.7-pp310-pypy310_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:c987dad82e8c65ebc985f5dae5e74a3beda9d0a2a4daf8a1115f3772b59e5141"}, + {file = "cryptography-45.0.7-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:c13b1e3afd29a5b3b2656257f14669ca8fa8d7956d509926f0b130b600b50ab7"}, + {file = "cryptography-45.0.7-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:4a862753b36620af6fc54209264f92c716367f2f0ff4624952276a6bbd18cbde"}, + {file = "cryptography-45.0.7-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:06ce84dc14df0bf6ea84666f958e6080cdb6fe1231be2a51f3fc1267d9f3fb34"}, + {file = "cryptography-45.0.7-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:d0c5c6bac22b177bf8da7435d9d27a6834ee130309749d162b26c3105c0795a9"}, + {file = "cryptography-45.0.7-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:2f641b64acc00811da98df63df7d59fd4706c0df449da71cb7ac39a0732b40ae"}, + {file = "cryptography-45.0.7-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:f5414a788ecc6ee6bc58560e85ca624258a55ca434884445440a810796ea0e0b"}, + {file = "cryptography-45.0.7-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:1f3d56f73595376f4244646dd5c5870c14c196949807be39e79e7bd9bac3da63"}, + {file = "cryptography-45.0.7.tar.gz", hash = "sha256:4b1654dfc64ea479c242508eb8c724044f1e964a47d1d1cacc5132292d851971"}, ] [package.dependencies] -cffi = {version = ">=1.12", markers = "platform_python_implementation != \"PyPy\""} +cffi = {version = ">=1.14", markers = "platform_python_implementation != \"PyPy\""} [package.extras] -docs = ["sphinx (>=5.3.0)", "sphinx-rtd-theme (>=3.0.0) ; python_version >= \"3.8\""] +docs = ["sphinx (>=5.3.0)", "sphinx-inline-tabs ; python_full_version >= \"3.8.0\"", "sphinx-rtd-theme (>=3.0.0) ; python_full_version >= \"3.8.0\""] docstest = ["pyenchant (>=3)", "readme-renderer (>=30.0)", "sphinxcontrib-spelling (>=7.3.1)"] -nox = ["nox (>=2024.4.15)", "nox[uv] (>=2024.3.2) ; python_version >= \"3.8\""] -pep8test = ["check-sdist ; python_version >= \"3.8\"", "click (>=8.0.1)", "mypy (>=1.4)", "ruff (>=0.3.6)"] +nox = ["nox (>=2024.4.15)", "nox[uv] (>=2024.3.2) ; python_full_version >= \"3.8.0\""] +pep8test = ["check-sdist ; python_full_version >= \"3.8.0\"", "click (>=8.0.1)", "mypy (>=1.4)", "ruff (>=0.3.6)"] sdist = ["build (>=1.0.0)"] ssh = ["bcrypt (>=3.1.5)"] -test = ["certifi (>=2024)", "cryptography-vectors (==44.0.2)", "pretend (>=0.7)", "pytest (>=7.4.0)", "pytest-benchmark (>=4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"] +test = ["certifi (>=2024)", "cryptography-vectors (==45.0.7)", "pretend (>=0.7)", "pytest (>=7.4.0)", "pytest-benchmark (>=4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"] test-randomorder = ["pytest-randomly"] [[package]] @@ -1080,8 +1082,8 @@ files = [ referencing = ">=0.31.0" [[package]] -name = "lc3" -version = "0.0.1" +name = "lc3py" +version = "1.1.3" description = "LC3 Codec library wrapper" optional = false python-versions = ">=3.10" @@ -1095,8 +1097,8 @@ dev = ["pytest"] [package.source] type = "git" url = "ssh://git@ssh.pstruebi.xyz:222/auracaster/liblc3.git" -reference = "7558637303106c7ea971e7bb8cedf379d3e08bcc" -resolved_reference = "7558637303106c7ea971e7bb8cedf379d3e08bcc" +reference = "ce2e41faf8c06d038df9f32504c61109a14130be" +resolved_reference = "ce2e41faf8c06d038df9f32504c61109a14130be" [[package]] name = "libusb-package" @@ -2950,4 +2952,4 @@ test = ["pytest", "pytest-asyncio"] [metadata] lock-version = "2.1" python-versions = ">=3.11" -content-hash = "9fe0e4746a6fca45e5aa9117ca177a5587c3a7b83cacb9427bdb960c4f0c7036" +content-hash = "3afe565be2664b3d7f1cfdb1c5a73d931c14e97d3622aef24ba2f06f78e00e2b" diff --git a/pyproject.toml b/pyproject.toml index 77c2ed9..dd44d25 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,8 +4,8 @@ version = "0.0.1" 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", + "bumble @ git+ssh://git@ssh.pstruebi.xyz:222/auracaster/bumble_mirror.git@6eba81e3ddb8ac0e4c336ca244892a0a8d43ba1c", + "lc3py @ git+ssh://git@ssh.pstruebi.xyz:222/auracaster/liblc3.git@ce2e41faf8c06d038df9f32504c61109a14130be", "aioconsole", "fastapi==0.115.11", "uvicorn==0.34.0", diff --git a/src/auracast/multicast.py b/src/auracast/multicast.py index abcc4f3..d003299 100644 --- a/src/auracast/multicast.py +++ b/src/auracast/multicast.py @@ -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 # ----------------------------------------------------------------------------- @@ -269,7 +287,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 +692,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 -- 2.52.0 From cc9a1787017e3686c1773af499ac4a5a0144d4a0 Mon Sep 17 00:00:00 2001 From: pstruebi Date: Fri, 19 Sep 2025 13:27:09 +0200 Subject: [PATCH 08/20] impelement password field in frontend --- src/auracast/multicast.py | 2 +- src/auracast/server/multicast_frontend.py | 10 +++++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/auracast/multicast.py b/src/auracast/multicast.py index d003299..0adcc19 100644 --- a/src/auracast/multicast.py +++ b/src/auracast/multicast.py @@ -692,7 +692,7 @@ if __name__ == "__main__": # TODO: encrypted streams are not working for big in config.bigs: - big.code = 'abcd' + #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 diff --git a/src/auracast/server/multicast_frontend.py b/src/auracast/server/multicast_frontend.py index 46e7d56..7208280 100644 --- a/src/auracast/server/multicast_frontend.py +++ b/src/auracast/server/multicast_frontend.py @@ -201,6 +201,13 @@ 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." + ) # 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") @@ -235,7 +242,7 @@ else: 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. Ensure AES67 sources are visible in PipeWire and click Refresh." + "No AES67/Network inputs found." ) st.warning(warn_text) if st.button("Refresh"): @@ -327,6 +334,7 @@ else: transport=TRANSPORT1, # transport for raspberry pi gpio header bigs = [ auracast_config.AuracastBigConfig( + code=(stream_passwort.strip() or None), name=stream_name, program_info=program_info, language=language, -- 2.52.0 From 576d669813c2bc04ad5dfc52915ee6fb82981e96 Mon Sep 17 00:00:00 2001 From: pstruebi Date: Fri, 19 Sep 2025 13:32:52 +0200 Subject: [PATCH 09/20] refactor: move transport configuration from frontend to backend --- src/auracast/server/multicast_frontend.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/src/auracast/server/multicast_frontend.py b/src/auracast/server/multicast_frontend.py index 7208280..4bff032 100644 --- a/src/auracast/server/multicast_frontend.py +++ b/src/auracast/server/multicast_frontend.py @@ -13,11 +13,7 @@ if 'stream_started' not in st.session_state: # 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}, @@ -122,7 +118,7 @@ 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 bigs=bigs1 ) config2 = None @@ -130,7 +126,7 @@ 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 bigs=bigs2 ) # Call /init and /init2 @@ -331,7 +327,7 @@ 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 bigs = [ auracast_config.AuracastBigConfig( code=(stream_passwort.strip() or None), -- 2.52.0 From 2355db22199db96754d8f6e39624813856681417 Mon Sep 17 00:00:00 2001 From: pstruebi Date: Fri, 19 Sep 2025 14:35:37 +0200 Subject: [PATCH 10/20] refactor: simplify service stop script by consolidating commands and removing redundant output --- src/service/stop_server_and_frontend.sh | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/src/service/stop_server_and_frontend.sh b/src/service/stop_server_and_frontend.sh index e6cf15b..f91fbd1 100644 --- a/src/service/stop_server_and_frontend.sh +++ b/src/service/stop_server_and_frontend.sh @@ -1,22 +1,16 @@ # 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 ---" +echo "Stopping auracast-server.service..." +systemctl --user stop auracast-server.service +systemctl --user disable auracast-server.service + + +sudo systemctl status auracast-frontend.service --no-pager 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." -- 2.52.0 From 75849080f157ac960ba77390b252e8b4826bcdb8 Mon Sep 17 00:00:00 2001 From: pstruebi Date: Fri, 19 Sep 2025 16:51:48 +0200 Subject: [PATCH 11/20] refactor: disable autostart and improve service shutdown with kill commands --- README.md | 7 +- src/auracast/server/multicast_server.py | 87 +++++++++---------- src/service/stop_server_and_frontend.sh | 10 +-- .../update_and_run_server_and_frontend.sh | 2 +- 4 files changed, 53 insertions(+), 53 deletions(-) diff --git a/README.md b/README.md index ccb0b8c..6e40be1 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,11 @@ sudo ./provision_domain_hostname.sh - 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. +# uart reset over hci does not work: +stty -F /dev/ttyAMA3 -hupcl +stty -F /dev/ttyAMA3 -a | grep -o 'hupcl' || echo "-hupcl is set" + + # Audio latency if there is hearable audio error with aes67, tune sess.latency.msec in pipewire-aes67.conf @@ -202,7 +207,7 @@ sudo ldconfig # refresh linker cache - sudo modprobe i2c-dev - echo i2c_bcm2835 | sudo tee -a /etc/modules - echo i2c-dev | sudo tee -a /etc/modules -- read temp /src/scripts/temp +- read temp /src/scripts/temp # 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 diff --git a/src/auracast/server/multicast_server.py b/src/auracast/server/multicast_server.py index 8f0e960..8bd2f34 100644 --- a/src/auracast/server/multicast_server.py +++ b/src/auracast/server/multicast_server.py @@ -263,58 +263,55 @@ async def _autostart_from_settings(): return while True: - try: - # Do not interfere if user started a stream manually in the meantime - if multicaster1 and multicaster1.get_status().get('is_streaming'): + # 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 - # 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( - 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, + # 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( + 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, - transport=TRANSPORT1, - bigs=bigs, ) - # Initialize and start - await initialize(conf) - return - except Exception: - log.warning("Autostart polling encountered an error", exc_info=True) + ] + conf = auracast_config.AuracastConfigGroup( + auracast_sampling_rate_hz=rate, + octets_per_frame=octets, + transport=TRANSPORT1, + bigs=bigs, + ) + # Initialize and start + await initialize(conf) + return await asyncio.sleep(2) except Exception: log.warning("Autostart task failed", exc_info=True) -@app.on_event("startup") -async def _startup_autostart_event(): - # Spawn the autostart task without blocking startup - asyncio.create_task(_autostart_from_settings()) +# @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") @@ -348,8 +345,6 @@ async def refresh_audio_inputs(force: bool = False): 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. diff --git a/src/service/stop_server_and_frontend.sh b/src/service/stop_server_and_frontend.sh index f91fbd1..d467b10 100644 --- a/src/service/stop_server_and_frontend.sh +++ b/src/service/stop_server_and_frontend.sh @@ -2,15 +2,15 @@ # Requires sudo privileges echo "Stopping auracast-frontend.service ..." -sudo systemctl stop auracast-frontend.service +sudo systemctl kill auracast-frontend.service sudo systemctl disable auracast-frontend.service echo "Stopping auracast-server.service..." -systemctl --user stop auracast-server.service +systemctl --user kill auracast-server.service systemctl --user disable auracast-server.service - +sudo systemctl kill auracast-server.service +sudo systemctl disable auracast-server.service +sudo rm -f /etc/systemd/system/auracast-server.service sudo systemctl status auracast-frontend.service --no-pager systemctl --user status auracast-server.service --no-pager - -echo "auracast-server and auracast-frontend services stopped, disabled, and status printed successfully." diff --git a/src/service/update_and_run_server_and_frontend.sh b/src/service/update_and_run_server_and_frontend.sh index fc93513..7dc42af 100644 --- a/src/service/update_and_run_server_and_frontend.sh +++ b/src/service/update_and_run_server_and_frontend.sh @@ -7,7 +7,7 @@ set -e # 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) +# Copy user service file for backend 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 -- 2.52.0 From c2fd0a8c46addcdd8b65a63ede2bdbec7406cb09 Mon Sep 17 00:00:00 2001 From: pstruebi Date: Mon, 22 Sep 2025 12:30:03 +0200 Subject: [PATCH 12/20] feat: add status indicator and improve UI layout in multicast frontend --- src/auracast/server/multicast_frontend.py | 26 ++++++++++++++++++++--- src/auracast/server/multicast_server.py | 21 ++++++++++++------ 2 files changed, 38 insertions(+), 9 deletions(-) diff --git a/src/auracast/server/multicast_frontend.py b/src/auracast/server/multicast_frontend.py index 4bff032..c8f726f 100644 --- a/src/auracast/server/multicast_frontend.py +++ b/src/auracast/server/multicast_frontend.py @@ -6,6 +6,9 @@ import requests from auracast import auracast_config import logging as log +# Set page configuration (tab title and icon) before using other Streamlit APIs +st.set_page_config(page_title="Castbox", page_icon="", layout="centered") + # Track whether WebRTC stream is active across Streamlit reruns if 'stream_started' not in st.session_state: st.session_state['stream_started'] = False @@ -30,7 +33,7 @@ try: except Exception: saved_settings = {} -st.title("🎙️ Auracast Audio Mode Control") +st.title("Auracast Audio Mode Control") # Audio mode selection with persisted default # Note: backend persists 'USB' for any device: source (including AES67). We default to 'USB' in that case. @@ -154,6 +157,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: @@ -277,8 +281,22 @@ else: 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'): @@ -295,6 +313,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: @@ -351,6 +370,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: diff --git a/src/auracast/server/multicast_server.py b/src/auracast/server/multicast_server.py index 8bd2f34..ba33fe1 100644 --- a/src/auracast/server/multicast_server.py +++ b/src/auracast/server/multicast_server.py @@ -102,8 +102,17 @@ async def initialize(conf: auracast_config.AuracastConfigGroup): # 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' + else: + audio_mode_persist = 'USB' # 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) @@ -307,11 +316,11 @@ async def _autostart_from_settings(): except Exception: log.warning("Autostart task failed", exc_info=True) - -# @app.on_event("startup") -# async def _startup_autostart_event(): -# # Spawn the autostart task without blocking startup -# asyncio.create_task(_autostart_from_settings()) +#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") -- 2.52.0 From 5e6dad7d6968cb859b8bb4d93f300a8bb5e343f9 Mon Sep 17 00:00:00 2001 From: pstruebi Date: Mon, 22 Sep 2025 13:11:04 +0200 Subject: [PATCH 13/20] add passwort for the frontend --- .gitignore | 4 +- src/auracast/server/multicast_frontend.py | 88 +++++++++++++++++++++ src/auracast/utils/frontend_auth.py | 96 +++++++++++++++++++++++ 3 files changed, 186 insertions(+), 2 deletions(-) create mode 100644 src/auracast/utils/frontend_auth.py diff --git a/.gitignore b/.gitignore index 6d6cf83..68ac071 100644 --- a/.gitignore +++ b/.gitignore @@ -35,10 +35,10 @@ env/ __pycache__/ # Exclude .env file from all platforms -*/.env +*.env wg_config/wg_confs/ -records/ +records/DISABLE_FRONTEND_PW src/auracast/server/stream_settings.json src/auracast/server/certs/per_device/ src/auracast/.env diff --git a/src/auracast/server/multicast_frontend.py b/src/auracast/server/multicast_frontend.py index c8f726f..1c78900 100644 --- a/src/auracast/server/multicast_frontend.py +++ b/src/auracast/server/multicast_frontend.py @@ -5,14 +5,73 @@ import streamlit as st import requests from auracast import auracast_config import logging as log +from dotenv import load_dotenv +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 st.set_page_config(page_title="Castbox", page_icon="", 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" @@ -444,6 +503,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' diff --git a/src/auracast/utils/frontend_auth.py b/src/auracast/utils/frontend_auth.py new file mode 100644 index 0000000..e1d4111 --- /dev/null +++ b/src/auracast/utils/frontend_auth.py @@ -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 -- 2.52.0 From 0201222bca2643864f9789b51b56f2d2887440fd Mon Sep 17 00:00:00 2001 From: pstruebi Date: Mon, 22 Sep 2025 13:20:22 +0200 Subject: [PATCH 14/20] add immediate rendering and assitive listening flags --- src/auracast/auracast_config.py | 1 + src/auracast/multicast.py | 10 +++++++++- src/auracast/server/multicast_frontend.py | 14 ++++++++++++++ src/auracast/server/multicast_server.py | 6 ++++++ 4 files changed, 30 insertions(+), 1 deletion(-) diff --git a/src/auracast/auracast_config.py b/src/auracast/auracast_config.py index dfeb1f9..2971dea 100644 --- a/src/auracast/auracast_config.py +++ b/src/auracast/auracast_config.py @@ -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, " diff --git a/src/auracast/multicast.py b/src/auracast/multicast.py index 0adcc19..5fe774c 100644 --- a/src/auracast/multicast.py +++ b/src/auracast/multicast.py @@ -188,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 [] + ) + + ( + [ + # Broadcast Audio Immediate Rendering flag (type 0x09), zero-length value + le_audio.Metadata.Entry(tag = le_audio.Metadata.Tag.ASSISTED_LISTENING_STREAM, data=b"") + ] + if global_config.assisted_listening_stream else [] ) ) diff --git a/src/auracast/server/multicast_frontend.py b/src/auracast/server/multicast_frontend.py index 1c78900..63acda2 100644 --- a/src/auracast/server/multicast_frontend.py +++ b/src/auracast/server/multicast_frontend.py @@ -267,6 +267,18 @@ else: 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") @@ -406,6 +418,8 @@ else: auracast_sampling_rate_hz=q['rate'], octets_per_frame=q['octets'], transport='', # is set in backend + assisted_listening_stream=assisted_listening, + immediate_rendering=immediate_rendering, bigs = [ auracast_config.AuracastBigConfig( code=(stream_passwort.strip() or None), diff --git a/src/auracast/server/multicast_server.py b/src/auracast/server/multicast_server.py index ba33fe1..217ff40 100644 --- a/src/auracast/server/multicast_server.py +++ b/src/auracast/server/multicast_server.py @@ -141,6 +141,8 @@ async def initialize(conf: auracast_config.AuracastConfigGroup): '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), 'timestamp': datetime.utcnow().isoformat() }) global_config_group = conf @@ -255,6 +257,8 @@ async def _autostart_from_settings(): 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"] @@ -307,6 +311,8 @@ async def _autostart_from_settings(): 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 -- 2.52.0 From f82febc1ce833030317df159dd6b1a70bf860504 Mon Sep 17 00:00:00 2001 From: pstruebi Date: Mon, 22 Sep 2025 13:34:22 +0200 Subject: [PATCH 15/20] feat: add favicon to Castbox web interface using relative path lookup --- src/auracast/server/multicast_frontend.py | 15 +++++++++++---- src/auracast/utils/favicon.ico | Bin 0 -> 15086 bytes 2 files changed, 11 insertions(+), 4 deletions(-) create mode 100644 src/auracast/utils/favicon.ico diff --git a/src/auracast/server/multicast_frontend.py b/src/auracast/server/multicast_frontend.py index 63acda2..bececa8 100644 --- a/src/auracast/server/multicast_frontend.py +++ b/src/auracast/server/multicast_frontend.py @@ -1,11 +1,14 @@ # 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, @@ -15,7 +18,11 @@ from auracast.utils.frontend_auth import ( ) # Set page configuration (tab title and icon) before using other Streamlit APIs -st.set_page_config(page_title="Castbox", page_icon="", layout="centered") +# 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() diff --git a/src/auracast/utils/favicon.ico b/src/auracast/utils/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..a24f0b2d1e19c10adfb22b6e07802938977e61b0 GIT binary patch literal 15086 zcmeHN*;AX>6<4SI1M<>$KXl1Mr%ltDJap1&U)sJjah^Pxblf-#)5Lagn!0fo+}5!} zZDX4)K+I+qgT*2XgT=mx&5{5iKmr8X0D;gh(9UB2dhQh>g5UvShq&&?%;kLde)pbx z&iT%FzoYXzBGLCm-xodejELwV(K|m7iCz$iM28N=`eGU{rm^Rq+vxvTB>KY-MWSbE z4&9;^v2h|I5zT9TIo5ce{zcY-|MwOp8$=%fE7YR*_txSzWApvtFsyS1%+2WGblKo= z%yVid>ToV#ad8&51qaRoIkWbeIoU(pbeLgzx zTt`by26~%I(I&};%kAK@GC4vkYw#^EVL2Ft+v~>s>^LmbDwwBKuo%=>3ix^RF8SaO z1^L|Y>iU-Fo~XCaXUBvh4-@yUz@jaL+oAlLUE=!hL>Vuy#{knv8m+$or%kqHubtLx zALDlQTi~#k!{hGQ_j<;U@rbOiA-o#?M(elB^fdP4=fF3m?3_77?RV7J`NaR|;1SLl z;aq(h-xonA!LJ|H2iSLs#Tto?jm6vcI3(VW)A;&_(fGWba1B&IgbdrSO=hGV2sNq`Byp-GH*Q1~qx7p=hf>H^o5h zx3f`SdY;EZgWVD|S6xA6=6k%g)@I^n`U$jDUqe;y2WXe(K%DbFZWpF-+1Xw@?LVbc zp*H^`^fr}3r|#hKl(hJtsJ(s$^=0SLF3BVwpFmRZ5gIEml3YcQA)3?YiWTplO4Q82nfFWW?`wf^!&%(J-hd{{AZT-0R+KCT)mf_VEir+J! zSG+}WcpVmud1R0GD7GJXsBiDY>ygIRR$-s3fpPdK+2LPs*`)}E>9-Hb_egyAv7Y&S zAT&p|FT=b!1xrhsgVTRQPX6Ap^OWonS=;*C?V#)+>~nBFC)irB_0!}7vBbc4_n+k{ zC`BqdMr|`s@gpHm@ih6Epo?&33O*qCfZzjy4+uUW_<-O8f)6|nA7Htc2<7AAIhg4E z2U}|eC`ZHZ#VN~&KCH#(Y*3bK+&-7>#m5uoFg;>>d?m+TbYpc5hDkNgXZKJZdyew_ zb;W1VB)$T5-(9FEZ|(7UFwiaCq#8vdPa}E#**A zp59G4?9MxRaJd$Fj+{|-Ujy!xoI`7E7RLjOdNt48vw8!IQA;`hv;3Zya@$Ow-E?4c zEXHxl`(H!e*~7R^n8BTrf1|b_1>)?#p@Q=N3-e}FWF~W3nU##&1s_A%Ax1aVQ7GCg z(IYE_>}D#;uN^1cLWBIzj$oEBh!JHIQvdoqdKyb`>Ey4uPNi3pI2Muf>C4cM^+Q59 zKw9#Pgmvg>{X1x^xJa_3a}0w$quu0R0ifupLUHO*vfE8=cU6}dDzar&&S{ifdIN@u zArz(lh0}_R_~O%7 zAieP!8p=P1qO%s&c^@M4AHT=Fwo0nXtu>jre(nhII6-!N1!IHlV6yEhKk2AZ zHlyf2uR}(>%tW6BQXJQJVHPN-n=iG>PLSy-hbzeDN3xNzPl@r%*&x zH!O#Y@RK%44$i;%Q>tkxAgtvA_pd6#d&~wc-A^MMRdP(HD);YnO&E}@nAgoT-nzSa7$qJaSV+it-)H3IP$r{Huh@--3S&FX(RC0>DW6n2M=aF_tcaTqSc-eyCaZH@E|^J{-74D~%3)d4MYJ4DuE z=Lp*)o=e~4yTLEs-!f-&uibqoQU2Yo*DT`TtW3`PgZ>u z(;>VPB{@$+UiKo@uZ?4RCITm}LG97H7o>1n1$lw_$Gb3}z;h z>CE59w}{TUgOEFM5AI8WS(r!Ohfe-0m^81`bC8mMnd$rB=HI8p`A_0C(;>VPg3CsA zD`Q9Lef}*>V~1fipQCU2(i7`HiMHh5Q_>@}vIL*E2X<>FY_n-3zwduS{w=zY9;`+| rz@iWSeNKE1!nI(3!3P8%5PU%J0l^0Z9}s-t(R_f)gM`2D&;tJjXaD>m literal 0 HcmV?d00001 -- 2.52.0 From e6ee3510dcad1687cc1b8ca47f628ec89b093a27 Mon Sep 17 00:00:00 2001 From: pstruebi Date: Mon, 22 Sep 2025 17:24:09 +0200 Subject: [PATCH 16/20] fix: set correct assisted listening stream flag value and make Demo mode default --- src/auracast/multicast.py | 4 +-- src/auracast/server/multicast_frontend.py | 32 ++++++++++++++++++++--- src/auracast/server/multicast_server.py | 20 +++++++++++++- 3 files changed, 49 insertions(+), 7 deletions(-) diff --git a/src/auracast/multicast.py b/src/auracast/multicast.py index 5fe774c..9a00846 100644 --- a/src/auracast/multicast.py +++ b/src/auracast/multicast.py @@ -193,8 +193,8 @@ async def init_broadcast( ) + ( [ - # Broadcast Audio Immediate Rendering flag (type 0x09), zero-length value - le_audio.Metadata.Entry(tag = le_audio.Metadata.Tag.ASSISTED_LISTENING_STREAM, data=b"") + # 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 [] diff --git a/src/auracast/server/multicast_frontend.py b/src/auracast/server/multicast_frontend.py index bececa8..e63fc25 100644 --- a/src/auracast/server/multicast_frontend.py +++ b/src/auracast/server/multicast_frontend.py @@ -103,17 +103,17 @@ st.title("Auracast Audio Mode Control") # Audio mode selection with persisted default # Note: backend persists 'USB' for any device: source (including AES67). We default to 'USB' in that case. -options = ["Webapp", "USB", "AES67", "Demo"] -saved_audio_mode = saved_settings.get("audio_mode", "Webapp") +options = ["Demo", "USB", "AES67", "Webapp"] +saved_audio_mode = saved_settings.get("audio_mode", "Demo") if saved_audio_mode not in options: # Map legacy/unknown modes to closest mapping = {"USB/Network": "USB", "Network": "AES67"} - saved_audio_mode = mapping.get(saved_audio_mode, "Webapp") + saved_audio_mode = mapping.get(saved_audio_mode, "Demo") audio_mode = st.selectbox( "Audio Mode", options, - index=options.index(saved_audio_mode) if saved_audio_mode in options else options.index("Webapp"), + 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, " @@ -138,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: @@ -171,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'], @@ -188,6 +208,8 @@ if audio_mode == "Demo": auracast_sampling_rate_hz=q['rate'], octets_per_frame=q['octets'], transport='', # is set in backend + assisted_listening_stream=assisted_listening, + immediate_rendering=immediate_rendering, bigs=bigs1 ) config2 = None @@ -196,6 +218,8 @@ if audio_mode == "Demo": auracast_sampling_rate_hz=q['rate'], octets_per_frame=q['octets'], transport='', # is set in backend + assisted_listening_stream=assisted_listening, + immediate_rendering=immediate_rendering, bigs=bigs2 ) # Call /init and /init2 diff --git a/src/auracast/server/multicast_server.py b/src/auracast/server/multicast_server.py index 217ff40..72e2a5b 100644 --- a/src/auracast/server/multicast_server.py +++ b/src/auracast/server/multicast_server.py @@ -26,7 +26,6 @@ from auracast.utils.sounddevice_utils import ( load_dotenv() # make sure pipewire sets latency -os.environ.setdefault("PULSE_LATENCY_MSEC", "3") 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 @@ -111,8 +110,11 @@ async def initialize(conf: auracast_config.AuracastConfigGroup): 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) @@ -143,6 +145,7 @@ async def initialize(conf: auracast_config.AuracastConfigGroup): '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 @@ -252,6 +255,7 @@ async def _autostart_from_settings(): and initializes streaming. """ try: + settings = load_stream_settings() or {} audio_mode = settings.get('audio_mode') input_device_name = settings.get('input_device') @@ -262,8 +266,21 @@ async def _autostart_from_settings(): 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: + audio_mode_persist = 'AES67' + os.environ.setdefault("PULSE_LATENCY_MSEC", "6") + else: + audio_mode_persist = 'USB' + 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 @@ -297,6 +314,7 @@ async def _autostart_from_settings(): # 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", -- 2.52.0 From 3edf1a54f334a6c8edf58beb1f159aea0cf05c12 Mon Sep 17 00:00:00 2001 From: pstruebi Date: Mon, 22 Sep 2025 17:24:16 +0200 Subject: [PATCH 17/20] config: set PTP0-Driver as default clock node and increase resync interval to 3ms --- src/service/aes67/pipewire-aes67.conf | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/service/aes67/pipewire-aes67.conf b/src/service/aes67/pipewire-aes67.conf index 812a56e..3f64320 100644 --- a/src/service/aes67/pipewire-aes67.conf +++ b/src/service/aes67/pipewire-aes67.conf @@ -21,6 +21,7 @@ context.properties = { #log.level = 2 #default.clock.quantum-limit = 8192 + default.clock.node = "PTP0-Driver" } context.spa-libs = { @@ -28,7 +29,7 @@ context.spa-libs = { } context.objects = [ - # An example clock reading from /dev/ptp0. You can also specify the network interface name, + # An example clock reading f16rom /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. @@ -46,8 +47,8 @@ context.objects = [ clock.interface = "eth0" #clock.device = "/dev/ptp0" #clock.id = tai - # Lower this in case of periodic out-of-sync - resync.ms = 1.5 + # Lower this in case16 of periodic out-of-sync + resync.ms = 3 object.export = true } } -- 2.52.0 From 4f54de7086b49c73a9a5b98c5a9d28e6922562ce Mon Sep 17 00:00:00 2001 From: pstruebi Date: Tue, 23 Sep 2025 17:14:23 +0200 Subject: [PATCH 18/20] refactor: remove unused audio_mode_persist variable in autostart settings --- src/auracast/server/multicast_server.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/auracast/server/multicast_server.py b/src/auracast/server/multicast_server.py index 72e2a5b..434859a 100644 --- a/src/auracast/server/multicast_server.py +++ b/src/auracast/server/multicast_server.py @@ -275,10 +275,8 @@ async def _autostart_from_settings(): 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") # Only auto-start device-based inputs; Webapp and Demo require external sources/UI -- 2.52.0 From 946e6e87b5624cde760b9ff3fb5d4917e5a185a6 Mon Sep 17 00:00:00 2001 From: pstruebi Date: Tue, 23 Sep 2025 17:22:00 +0200 Subject: [PATCH 19/20] config: increase PTP timestamp timeout from 20ms to 100ms for AES67 --- src/service/aes67/ptp_aes67_1.conf | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/service/aes67/ptp_aes67_1.conf b/src/service/aes67/ptp_aes67_1.conf index c4c1283..4e1b070 100644 --- a/src/service/aes67/ptp_aes67_1.conf +++ b/src/service/aes67/ptp_aes67_1.conf @@ -15,5 +15,4 @@ dscp_general 0 # QoS for general messages step_threshold 1 # Fast convergence on time jumps - -tx_timestamp_timeout 20 \ No newline at end of file +tx_timestamp_timeout 100 \ No newline at end of file -- 2.52.0 From 09d9a3b872e5a2226e252ba7b8b9d899abbf5830 Mon Sep 17 00:00:00 2001 From: pstruebi Date: Wed, 24 Sep 2025 15:03:04 +0200 Subject: [PATCH 20/20] docs: add audio recording debug instructions to README --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.md b/README.md index 6e40be1..fbe9dc3 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,12 @@ sudo ./provision_domain_hostname.sh - 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. +# record audio and save to file for debugging +pw-record --target="AVIOUSB-8f6326 : 2:receive_Left" --rate=48000 --channels=1 --format=s24 /tmp/aes67_test.wav & +RECORD_PID=$! +sleep 30 +kill $RECORD_PID + # uart reset over hci does not work: stty -F /dev/ttyAMA3 -hupcl stty -F /dev/ttyAMA3 -a | grep -o 'hupcl' || echo "-hupcl is set" -- 2.52.0