use no pipewire for usb

This commit is contained in:
pstruebi
2025-10-15 14:18:54 +02:00
parent 00a832a1fd
commit 96e4de6e21
4 changed files with 81 additions and 101 deletions
+1 -1
View File
@@ -177,7 +177,7 @@ git checkout 9abe5fe7db729280080a0bbc1397a528cd3ce658
rm -rf build
cmake -S . -B build -G"Unix Makefiles" \
-DBUILD_SHARED_LIBS=ON \
-DPA_USE_ALSA=OFF \
-DPA_USE_ALSA=ON \
-DPA_USE_PULSEAUDIO=ON \
-DPA_USE_JACK=OFF
cmake --build build -j$(nproc)
+43
View File
@@ -53,6 +53,18 @@ from auracast.utils.read_lc3_file import read_lc3_file
from auracast.utils.network_audio_receiver import NetworkAudioReceiverUncoded
from auracast.utils.webrtc_audio_input import WebRTCAudioInput
# Configure tight ALSA latency via sounddevice defaults, if requested
try:
import os as _os
import sounddevice as _sd # type: ignore
_lat_ms = float(_os.environ.get('ALSA_LATENCY_MSEC', '') or '0')
if _lat_ms > 0:
_sec = _lat_ms / 1000.0
# Apply to both input and output defaults; input is what we use
_sd.default.latency = (_sec, _sec)
except Exception:
pass
# Instantiate WebRTC audio input for streaming (can be used per-BIG or globally)
@@ -547,6 +559,21 @@ class Streamer():
for attempt in range(1, max_attempts + 1):
try:
pcm_format = await audio_input.open()
# Debug: report which backend/device was opened
try:
dev_index = None
if isinstance(audio_source, str) and audio_source.startswith('device:'):
try:
dev_index = int(audio_source[7:])
except Exception:
dev_index = None
if dev_index is None:
dev_index = _sd.default.device[0]
_dev = _sd.query_devices(dev_index)
_host = _sd.query_hostapis()[_dev['hostapi']]['name']
logging.info("Opened input device index=%s name=%s hostapi=%s", dev_index, _dev.get('name'), _host)
except Exception:
pass
break # success
except _sd.PortAudioError as err:
# -9985 == paDeviceUnavailable
@@ -666,6 +693,22 @@ class Streamer():
await big['iso_queue'].write(lc3_frame)
# Every 100 packets, if input is a SoundDevice stream, log available frames
pkt_counter = big.get('pkt_counter', 0) + 1
big['pkt_counter'] = pkt_counter
if pkt_counter % 100 == 0:
try:
ai = big.get('audio_input')
if ai is not None and hasattr(ai, '_stream') and getattr(ai, '_stream') is not None:
sd_stream = getattr(ai, '_stream')
avail = None
# sounddevice streams expose read_available (frames)
if hasattr(sd_stream, 'read_available'):
avail = sd_stream.read_available
logging.info('SD read_available (frames) after %d packets: %s', pkt_counter, avail)
except Exception:
pass
if all(stream_finished): # Take into account that multiple files have different lengths
logging.info('All streams finished, stopping streamer')
self.is_streaming = False
+6 -6
View File
@@ -60,18 +60,18 @@ if __name__ == "__main__":
os.chdir(os.path.dirname(__file__))
# Load .env located next to this script (only uppercase keys will be referenced)
load_dotenv(dotenv_path='.env')
# Default tight ALSA latency (ms); can be overridden via environment
os.environ.setdefault('ALSA_LATENCY_MSEC', '2')
os.environ.setdefault("PULSE_LATENCY_MSEC", "3")
# Refresh device cache and list inputs
refresh_pw_cache()
usb_inputs = get_usb_pw_inputs()
logging.info("USB pw inputs:")
logging.info("USB ALSA inputs:")
for i, d in usb_inputs:
logging.info(f"{i}: {d['name']} in={d['max_input_channels']}")
aes67_inputs = get_network_pw_inputs()
logging.info("AES67 pw inputs:")
logging.info("AES67/Network ALSA inputs:")
for i, d in aes67_inputs:
logging.info(f"{i}: {d['name']} in={d['max_input_channels']}")
@@ -118,7 +118,7 @@ if __name__ == "__main__":
TRANSPORT1 = 'serial:/dev/ttyAMA3,1000000,rtscts' # transport for raspberry pi gpio header
TRANSPORT2 = 'serial:/dev/ttyAMA4,1000000,rtscts' # transport for raspberry pi gpio header
# Capture at 48 kHz to avoid PipeWire resampler latency; encode LC3 at 24 kHz
# Capture at 48 kHz to avoid resampler latency; encode LC3 at 24 kHz
CAPTURE_SRATE = 48000
LC3_SRATE = 24000
OCTETS_PER_FRAME=60
@@ -157,7 +157,7 @@ if __name__ == "__main__":
octets_per_frame = OCTETS_PER_FRAME,
transport=TRANSPORT1
)
#config.debug = True
config.debug = True
logging.info(config.model_dump_json(indent=2))
multicast.run_async(
+31 -94
View File
@@ -1,29 +1,12 @@
import sounddevice as sd
import os, re, json, subprocess
import re
def devices_by_backend(backend_name: str):
hostapis = sd.query_hostapis() # list of host APIs
# find the host API index by (case-insensitive) name match
try:
hostapi_idx = next(
i for i, ha in enumerate(hostapis)
if backend_name.lower() in ha['name'].lower()
)
except StopIteration:
raise ValueError(f"No host API matching {backend_name!r}. "
f"Available: {[ha['name'] for ha in hostapis]}")
# return (global_index, device_dict) pairs filtered by that host API
return [(i, d) for i, d in enumerate(sd.query_devices())
if d['hostapi'] == hostapi_idx]
def _pa_like_hostapi_index():
def _alsa_hostapi_index():
"""Return the PortAudio host API index for ALSA."""
for i, ha in enumerate(sd.query_hostapis()):
if any(k in ha["name"] for k in ("PipeWire", "PulseAudio")):
if 'alsa' in ha['name'].lower():
return i
raise RuntimeError("PipeWire/PulseAudio host API not present in PortAudio.")
def _pw_dump():
return json.loads(subprocess.check_output(["pw-dump"]))
raise RuntimeError("ALSA host API not present in PortAudio.")
def _sd_refresh():
"""Force PortAudio to re-enumerate devices on next query.
@@ -35,98 +18,52 @@ def _sd_refresh():
sd._terminate() # private API, acceptable for runtime refresh
sd._initialize()
def _sd_matches_from_names(pa_idx, names):
names_l = {n.lower() for n in names if n}
out = []
for i, d in enumerate(sd.query_devices()):
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
# Module-level caches for device lists
_usb_inputs_cache = []
_network_inputs_cache = []
def get_usb_pw_inputs():
"""Return cached list of USB PipeWire inputs."""
"""Return cached list of USB ALSA inputs."""
return _usb_inputs_cache
def get_network_pw_inputs():
"""Return cached list of Network/AES67 PipeWire inputs."""
"""Return cached list of non-USB ALSA inputs (used for AES67 selection heuristics)."""
return _network_inputs_cache
def refresh_pw_cache():
"""
Performs a full device scan and updates the internal caches for both USB
and Network audio devices. This is a heavy operation and should not be
called frequently or during active streams.
ALSA-only device scan using PortAudio via sounddevice. Updates internal
caches for USB and non-USB input devices. Heuristic: device name contains
'USB' -> USB; everything else -> non-USB.
"""
global _usb_inputs_cache, _network_inputs_cache
# Force PortAudio to re-enumerate devices
_sd_refresh()
pa_idx = _pa_like_hostapi_index()
pw = _pw_dump()
try:
alsa_idx = _alsa_hostapi_index()
except Exception:
# Fallback to first host API if ALSA not found
alsa_idx = 0
# --- Pass 1: Map device.id to device.bus ---
device_bus = {}
for obj in pw:
if obj.get("type") == "PipeWire:Interface:Device":
props = (obj.get("info") or {}).get("props") or {}
device_bus[obj["id"]] = (props.get("device.bus") or "").lower()
devices = sd.query_devices()
alsa_inputs = [
(i, d)
for i, d in enumerate(devices)
if d.get('hostapi') == alsa_idx and d.get('max_input_channels', 0) > 0
]
# --- Pass 2: Identify all USB and Network nodes ---
usb_input_names = set()
network_input_names = set()
for obj in pw:
if obj.get("type") != "PipeWire:Interface:Node":
continue
props = (obj.get("info") or {}).get("props") or {}
media = (props.get("media.class") or "").lower()
if "source" not in media and "stream/input" not in media:
usb, nonusb = [], []
for i, d in alsa_inputs:
name_l = str(d.get('name', '')).lower()
if 'monitor' in name_l:
continue
if 'usb' in name_l or re.search(r'\busb\b', name_l):
usb.append((i, d))
else:
nonusb.append((i, d))
nname = (props.get("node.name") or "")
ndesc = (props.get("node.description") or "")
# Skip all monitor sources
if ".monitor" in nname.lower() or "monitor" in ndesc.lower():
continue
# Check for USB
bus = (props.get("device.bus") or device_bus.get(props.get("device.id")) or "").lower()
if bus == "usb":
usb_input_names.add(ndesc or nname)
continue # A device is either USB or Network, not both
# Heuristics for Network/AES67/RTP
text = (nname + " " + ndesc).lower()
media_name = (props.get("media.name") or "").lower()
node_group = (props.get("node.group") or "").lower()
node_network_flag = bool(props.get("node.network"))
has_rtp_keys = any(k in props for k in ("rtp.session", "rtp.source.ip"))
has_sess_keys = any(k in props for k in ("sess.name", "sess.media"))
is_network = (
bus == "network" or
node_network_flag or
"rtp" in media_name or
any(k in text for k in ("rtp", "sap", "aes67", "network", "raop", "airplay")) or
has_rtp_keys or
has_sess_keys or
("pipewire.ptp" in node_group)
)
if is_network:
network_input_names.add(ndesc or nname)
# --- Final Step: Update caches ---
_usb_inputs_cache = _sd_matches_from_names(pa_idx, usb_input_names)
_network_inputs_cache = _sd_matches_from_names(pa_idx, network_input_names)
_usb_inputs_cache = usb
_network_inputs_cache = nonusb
# Populate cache on initial module load