From e80ff79d67f871846c327d73326b25551970b623 Mon Sep 17 00:00:00 2001 From: pstruebi Date: Mon, 8 Sep 2025 14:02:36 +0200 Subject: [PATCH] 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()