diff --git a/src/auracast/server/multicast_frontend.py b/src/auracast/server/multicast_frontend.py index 5ff654e..5bd1645 100644 --- a/src/auracast/server/multicast_frontend.py +++ b/src/auracast/server/multicast_frontend.py @@ -66,7 +66,12 @@ if audio_mode in ["Webapp", "USB"]: if default_input not in input_options: default_input = input_options[0] - selected_option = st.selectbox("Input Device", input_options, index=input_options.index(default_input)) + col1, col2 = st.columns([3, 1], vertical_alignment="bottom") + with col1: + selected_option = st.selectbox("Input Device", input_options, index=input_options.index(default_input)) + with col2: + if st.button("Refresh"): + st.rerun() # We send only the numeric/card identifier (before :) or 'default' input_device = selected_option.split(":", 1)[0] if ":" in selected_option else selected_option else: @@ -76,23 +81,22 @@ if audio_mode in ["Webapp", "USB"]: if stop_stream: try: - r = requests.post(f"{BACKEND_URL}/stop_audio") - if r.status_code == 200: + r = requests.post(f"{BACKEND_URL}/stop_audio").json() + if r['was_running']: st.success("Stream Stopped!") else: - st.error(f"Failed to stop: {r.text}") + st.success("Stream was not running.") except Exception as e: st.error(f"Error: {e}") if start_stream: # Always send stop to ensure backend is in a clean state, regardless of current status - try: - requests.post(f"{BACKEND_URL}/stop_audio", timeout=5) - except Exception: - # Ignore connection or 500 errors – backend may not be running yet - pass + r = requests.post(f"{BACKEND_URL}/stop_audio").json() + if r['was_running']: + st.success("Stream Stopped!") + # Small pause lets backend fully release audio devices before re-init - import time; time.sleep(0.7) + import time; time.sleep(1) # Prepare config using the model (do NOT send qos_config, only relevant fields) q = quality_map[quality] config = auracast_config.AuracastConfigGroup( diff --git a/src/auracast/server/multicast_server.py b/src/auracast/server/multicast_server.py index aac6f5b..62ee6d7 100644 --- a/src/auracast/server/multicast_server.py +++ b/src/auracast/server/multicast_server.py @@ -3,6 +3,7 @@ import os import logging as log import uuid import json +import sys from datetime import datetime import asyncio import numpy as np @@ -14,9 +15,18 @@ from aiortc import RTCPeerConnection, RTCSessionDescription, MediaStreamTrack import av import av.audio.layout import sounddevice as sd # type: ignore -from typing import List, Set +from typing import Set import traceback + +PTIME = 40 # TODO: seems to have no effect at all +pcs: Set[RTCPeerConnection] = set() # keep refs so they don’t GC early + +class Offer(BaseModel): + sdp: str + type: str + + # Path to persist stream settings STREAM_SETTINGS_FILE = os.path.join(os.path.dirname(__file__), 'stream_settings.json') @@ -38,6 +48,7 @@ def save_stream_settings(settings: dict): except Exception as e: log.error('Unable to persist stream settings: %s', e) + app = FastAPI() # Allow CORS for frontend on localhost @@ -151,9 +162,12 @@ async def stop_audio(): await asyncio.gather(*close_tasks, return_exceptions=True) # Now shut down the multicaster and release audio devices + running=False if multicaster is not None: await multicaster.stop_streaming() - return {"status": "stopped"} + running=True + + return {"status": "stopped", "was_running": running} except Exception as e: raise HTTPException(status_code=500, detail=str(e)) @@ -172,25 +186,25 @@ async def get_status(): async def list_audio_inputs(): """Return available hardware audio input devices for USB mode.""" try: + # Re-scan devices on Linux, see https://github.com/spatialaudio/python-sounddevice/issues/16 + if sys.platform == 'linux': + log.info("Re-initializing sounddevice to scan for new devices") + sd._terminate() + sd._initialize() devs = sd.query_devices() inputs = [ {"id": idx, "name": d["name"]} for idx, d in enumerate(devs) if d.get("max_input_channels", 0) > 0 and ("(hw:" in d["name"].lower() or "usb" in d["name"].lower()) ] + log.info('Found %d audio input devices:', len(inputs)) + for i in inputs: + log.info(' %s', i) return {"inputs": inputs} except Exception as e: raise HTTPException(status_code=500, detail=str(e)) -PTIME = 160 # TODO: seems to have no effect at all -pcs: Set[RTCPeerConnection] = set() # keep refs so they don’t GC early - -class Offer(BaseModel): - sdp: str - type: str - - @app.post("/offer") async def offer(offer: Offer): log.info("/offer endpoint called") @@ -210,7 +224,9 @@ async def offer(offer: Offer): # create directory for records - only for testing os.makedirs("./records", exist_ok=True) - await multicaster.start_streaming() + # Do NOT start the streamer yet – we'll start it lazily once we actually + # receive the first audio frame, ensuring WebRTCAudioInput is ready and + # avoiding race-conditions on restarts. @pc.on("track") async def on_track(track: MediaStreamTrack): log.info(f"{id_}: track {track.kind} received") @@ -224,6 +240,12 @@ async def offer(offer: Offer): log.info( f"{id_}: frame sample_rate={frame.sample_rate}, samples_per_channel={frame.samples}, planes={frame.planes}" ) + # Lazily start the streamer now that we know a track exists. + if multicaster.streamer is None: + await multicaster.start_streaming() + # Yield control so the Streamer coroutine has a chance to + # create the WebRTCAudioInput before we push samples. + await asyncio.sleep(0) first = False # in stereo case this is interleaved data format frame_array = frame.to_ndarray() @@ -243,7 +265,6 @@ async def offer(offer: Offer): log.info(f"mono_array.shape: {mono_array.shape}") - frame_array = frame.to_ndarray() # Flatten in case it's (1, N) or (N,)