feat: add PipeWire network input support and improve device selection logic
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
Reference in New Issue
Block a user