diff --git a/README.md b/README.md index e4f5deb..4b3e445 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/src/auracast/multicast.py b/src/auracast/multicast.py index 93792e5..17ebe43 100644 --- a/src/auracast/multicast.py +++ b/src/auracast/multicast.py @@ -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 diff --git a/src/auracast/multicast_script.py b/src/auracast/multicast_script.py index 553cebe..cafff15 100644 --- a/src/auracast/multicast_script.py +++ b/src/auracast/multicast_script.py @@ -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( diff --git a/src/auracast/utils/sounddevice_utils.py b/src/auracast/utils/sounddevice_utils.py index 7db59a4..82c5e3f 100644 --- a/src/auracast/utils/sounddevice_utils.py +++ b/src/auracast/utils/sounddevice_utils.py @@ -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