feat: split USB/Network mode into separate USB and AES67 input options with dedicated device lists
This commit is contained in:
@@ -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:<name> 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'],
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -42,17 +42,25 @@ def _sd_matches_from_names(pa_idx, names):
|
||||
if d["hostapi"] != pa_idx or d["max_input_channels"] <= 0:
|
||||
continue
|
||||
dn = d["name"].lower()
|
||||
# Exclude monitor devices (e.g., "Monitor of ...") to avoid false positives
|
||||
if "monitor" in dn:
|
||||
continue
|
||||
if any(n in dn for n in names_l):
|
||||
out.append((i, d))
|
||||
return out
|
||||
|
||||
def list_usb_pw_inputs():
|
||||
def list_usb_pw_inputs(refresh: bool = True):
|
||||
"""
|
||||
Return [(device_index, device_dict), ...] for PipeWire **input** nodes
|
||||
backed by **USB** devices (excludes monitor sources).
|
||||
|
||||
Parameters:
|
||||
- refresh (bool): If True (default), force PortAudio to re-enumerate devices
|
||||
before mapping. Set to False to avoid disrupting active streams.
|
||||
"""
|
||||
# Refresh PortAudio so we see newly added nodes before mapping
|
||||
_sd_refresh()
|
||||
if refresh:
|
||||
_sd_refresh()
|
||||
pa_idx = _pa_like_hostapi_index()
|
||||
pw = _pw_dump()
|
||||
|
||||
@@ -84,13 +92,18 @@ def list_usb_pw_inputs():
|
||||
# Map to sounddevice devices on PipeWire host API
|
||||
return _sd_matches_from_names(pa_idx, usb_input_names)
|
||||
|
||||
def list_network_pw_inputs():
|
||||
def list_network_pw_inputs(refresh: bool = True):
|
||||
"""
|
||||
Return [(device_index, device_dict), ...] for PipeWire **input** nodes that
|
||||
look like network/AES67/RTP sources (excludes monitor sources).
|
||||
|
||||
Parameters:
|
||||
- refresh (bool): If True (default), force PortAudio to re-enumerate devices
|
||||
before mapping. Set to False to avoid disrupting active streams.
|
||||
"""
|
||||
# Refresh PortAudio so we see newly added nodes before mapping
|
||||
_sd_refresh()
|
||||
if refresh:
|
||||
_sd_refresh()
|
||||
pa_idx = _pa_like_hostapi_index()
|
||||
pw = _pw_dump()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user