diff --git a/src/auracast/multicast.py b/src/auracast/multicast.py index bf1b343..aa05cdb 100644 --- a/src/auracast/multicast.py +++ b/src/auracast/multicast.py @@ -659,7 +659,7 @@ class Streamer(): enable_drift_compensation = getattr(global_config, 'enable_adaptive_frame_dropping', False) # Hardcoded parameters (unit: milliseconds) drift_threshold_ms = 2.0 if enable_drift_compensation else 0.0 - static_drop_ms = 0.5 if enable_drift_compensation else 0.0 + static_drop_ms = 1 if enable_drift_compensation else 0.0 # Guard interval measured in LC3 frames (10 ms each); 50 => 500 ms cooldown discard_guard_frames = int(2*sample_rate / 1000) if enable_drift_compensation else 0 # Derived sample counts diff --git a/src/auracast/multicast_script.py b/src/auracast/multicast_script.py index 97b05c8..01c4fdd 100644 --- a/src/auracast/multicast_script.py +++ b/src/auracast/multicast_script.py @@ -21,17 +21,13 @@ Environment variables - LANGUATE: ISO 639-3 language code used by config (intentional key name). Default: "deu". -- ALSA_LATENCY_MSEC: ALSA latency hint in milliseconds. - Default: 2. Examples (.env) --------------- LOG_LEVEL=DEBUG BROADCAST_NAME=MyBroadcast PROGRAM_INFO="Live announcements" -LANGUATE=deu -ALSA_LATENCY_MSEC=2 -""" +LANGUATE=deu""" import logging import os import time @@ -40,6 +36,9 @@ from auracast import multicast from auracast import auracast_config from auracast.utils.sounddevice_utils import ( get_alsa_usb_inputs, + get_network_pw_inputs, # PipeWire network (AES67) inputs + refresh_pw_cache, + resolve_input_device_index, ) @@ -53,21 +52,75 @@ if __name__ == "__main__": # Load .env located next to this script (only uppercase keys will be referenced) load_dotenv(dotenv_path='.env') - # List USB ALSA inputs + # Refresh PipeWire cache and list devices (Network only for PW; USB via ALSA) + try: + refresh_pw_cache() + except Exception: + pass + pw_net = get_network_pw_inputs() + # List PipeWire Network inputs + logging.info("PipeWire Network inputs:") + for i, d in pw_net: + logging.info(f"{i}: {d['name']} in={d.get('max_input_channels', 0)}") + + # Also list USB ALSA inputs (fallback path) usb_inputs = get_alsa_usb_inputs() logging.info("USB ALSA inputs:") for i, d in usb_inputs: logging.info(f"{i}: {d['name']} in={d['max_input_channels']}") - # Loop until a USB input becomes available + # Optional device selection via .env: match by string or substring (uppercase keys only) + device_match = os.environ.get('INPUT_DEVICE') + device_match = device_match.strip() if isinstance(device_match, str) else None + + # Loop until a device becomes available (prefer PW Network, then ALSA USB) selected_dev = None while True: - current = get_alsa_usb_inputs() - if current: - input_sel, selected_dev = current[0] + try: + refresh_pw_cache() + except Exception: + pass + pw_net = get_network_pw_inputs() + # 1) Try to satisfy explicit .env match on PW Network + if device_match and pw_net: + for _, d in pw_net: + name = d.get('name', '') + if device_match in name: + idx = resolve_input_device_index(name) + if idx is not None: + input_sel = idx + selected_dev = d + logging.info(f"Selected Network input by match '{device_match}' (PipeWire): index={input_sel}, device={name}") + break + if selected_dev is not None: + break + if pw_net and selected_dev is None: + _, d0 = pw_net[0] + idx = resolve_input_device_index(d0.get('name', '')) + if idx is not None: + input_sel = idx + selected_dev = d0 + logging.info(f"Selected first Network input (PipeWire): index={input_sel}, device={d0['name']}") + break + current_alsa = get_alsa_usb_inputs() + # 2) Try to satisfy explicit .env match on ALSA USB + if device_match and current_alsa and selected_dev is None: + matched = None + for idx_alsa, d in current_alsa: + name = d.get('name', '') + if device_match in name: + matched = (idx_alsa, d) + break + if matched is not None: + input_sel, selected_dev = matched + logging.info(f"Selected USB input by match '{device_match}' (ALSA): index={input_sel}, device={selected_dev['name']}") + break + # Fallback to first ALSA USB + if current_alsa and selected_dev is None: + input_sel, selected_dev = current_alsa[0] logging.info(f"Selected first USB input (ALSA): index={input_sel}, device={selected_dev['name']}") break - logging.info("Waiting for USB input (ALSA)... retrying in 2s") + logging.info("Waiting for audio input (prefer PW Network, then ALSA USB)... retrying in 2s") time.sleep(2) TRANSPORT1 = 'serial:/dev/ttyAMA3,1000000,rtscts' # transport for raspberry pi gpio header diff --git a/src/auracast/server/multicast_server.py b/src/auracast/server/multicast_server.py index 04d2865..267f36f 100644 --- a/src/auracast/server/multicast_server.py +++ b/src/auracast/server/multicast_server.py @@ -26,8 +26,9 @@ import sounddevice as sd # type: ignore from typing import Set import traceback from auracast.utils.sounddevice_utils import ( - get_usb_pw_inputs, get_network_pw_inputs, + get_alsa_usb_inputs, + resolve_input_device_index, refresh_pw_cache, ) from auracast.utils.reset_utils import reset_nrf54l @@ -51,19 +52,7 @@ 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. - - 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 +# Device resolution is centralized in utils.resolve_input_device_index def _hydrate_settings_cache_from_disk() -> None: @@ -115,7 +104,7 @@ app.add_middleware( # Initialize global configuration global_config_group = auracast_config.AuracastConfigGroup() -class StreamerWorker: +class StreamerWorker: # TODO: is wraping in this Worker stricly nececcarry ? """Owns multicaster(s) on a dedicated asyncio loop in a background thread.""" def __init__(self) -> None: @@ -189,14 +178,20 @@ class StreamerWorker: if first_source.startswith('device:'): input_device_name = first_source.split(':', 1)[1] if ':' in first_source else None try: - usb_names = {d.get('name') for _, d in get_usb_pw_inputs()} + alsa_usb_names = {d.get('name') for _, d in get_alsa_usb_inputs()} + except Exception: + alsa_usb_names = set() + try: net_names = {d.get('name') for _, d in get_network_pw_inputs()} except Exception: - usb_names, net_names = set(), set() + net_names = set() audio_mode_persist = 'AES67' if (input_device_name in net_names) else 'USB' - # Map device name to index and configure input_format - device_index = int(input_device_name) if (input_device_name and input_device_name.isdigit()) else get_device_index_by_name(input_device_name or '') + # Map device name to index using centralized resolver + if input_device_name and input_device_name.isdigit(): + device_index = int(input_device_name) + else: + device_index = resolve_input_device_index(input_device_name or '') if device_index is None: raise HTTPException(status_code=400, detail=f"Audio device '{input_device_name}' not found.") for big in conf.bigs: @@ -260,7 +255,16 @@ class StreamerWorker: for big in conf.bigs: if big.audio_source.startswith('device:'): device_name = big.audio_source.split(':', 1)[1] - device_index = get_device_index_by_name(device_name) + # Resolve backend preference by membership + try: + net_names = {d.get('name') for _, d in get_network_pw_inputs()} + except Exception: + net_names = set() + try: + alsa_usb_names = {d.get('name') for _, d in get_alsa_usb_inputs()} + except Exception: + alsa_usb_names = set() + device_index = resolve_input_device_index(device_name) if device_index is None: raise HTTPException(status_code=400, detail=f"Audio device '{device_name}' not found.") big.audio_source = f'device:{device_index}' @@ -504,11 +508,11 @@ async def _startup_autostart_event(): @app.get("/audio_inputs_pw_usb") async def audio_inputs_pw_usb(): - """List PipeWire USB input nodes from cache.""" + """List USB input devices using ALSA backend (USB is ALSA in our scheme).""" try: devices = [ {"id": idx, "name": dev.get("name"), "max_input_channels": dev.get("max_input_channels", 0)} - for idx, dev in get_usb_pw_inputs() + for idx, dev in get_alsa_usb_inputs() ] return {"inputs": devices} except Exception as e: diff --git a/src/auracast/utils/sounddevice_utils.py b/src/auracast/utils/sounddevice_utils.py index 12463fa..9d7c1f8 100644 --- a/src/auracast/utils/sounddevice_utils.py +++ b/src/auracast/utils/sounddevice_utils.py @@ -53,6 +53,74 @@ def _sd_matches_from_names(pa_idx, names): out.append((i, d)) return out +def get_device_index_by_name(name: str, backend: str | None = None): + """Return the sounddevice index for an input device with exact name. + + If backend is provided, restrict to that backend: + - 'ALSA' uses the ALSA host API + - 'PipeWire' or 'PulseAudio' use the PipeWire/Pulse host API index + Returns None if not found. + """ + try: + devices = sd.query_devices() + hostapi_filter = None + if backend: + if backend.lower() == 'alsa': + try: + alsa = devices_by_backend('ALSA') + alsa_indices = {idx for idx, _ in alsa} + hostapi_filter = ('indices', alsa_indices) + except Exception: + return None + else: + try: + pa_idx = _pa_like_hostapi_index() + hostapi_filter = ('hostapi', pa_idx) + except Exception: + return None + for idx, d in enumerate(devices): + if d.get('max_input_channels', 0) <= 0: + continue + if d.get('name') != name: + continue + if hostapi_filter: + kind, val = hostapi_filter + if kind == 'indices' and idx not in val: + continue + if kind == 'hostapi' and d.get('hostapi') != val: + continue + return idx + except Exception: + return None + return None + +def resolve_input_device_index(name: str): + """Resolve device index by exact name preferring backend by device type. + + - If name is known PipeWire Network device, use PipeWire backend. + - Else if name is known ALSA USB device, use ALSA backend. + - Else fallback to any backend. + Returns None if not found. + """ + try: + net_names = {d.get('name') for _, d in get_network_pw_inputs()} + except Exception: + net_names = set() + try: + alsa_usb_names = {d.get('name') for _, d in get_alsa_usb_inputs()} + except Exception: + alsa_usb_names = set() + + if name in net_names: + idx = get_device_index_by_name(name, backend='PipeWire') + if idx is not None: + return idx + if name in alsa_usb_names: + idx = get_device_index_by_name(name, backend='ALSA') + if idx is not None: + return idx + return get_device_index_by_name(name, backend=None) + # Module-level caches for device lists _usb_inputs_cache = [] _network_inputs_cache = []