improve network audio selection

This commit is contained in:
pstruebi
2025-08-27 10:01:28 +02:00
parent 6e021de1c7
commit 49ceb3e597
2 changed files with 97 additions and 10 deletions

View File

@@ -1,5 +1,56 @@
"""
multicast_script
=================
Loads environment variables from a .env file located next to this script
and configures the multicast broadcast. Only UPPERCASE keys are read.
Environment variables
---------------------
- LOG_LEVEL: Logging level for the script.
Default: INFO. Examples: DEBUG, INFO, WARNING, ERROR.
- INPUT: Select audio capture source.
Values:
- "usb" (default): first available USB input device.
- "aes67": select AES67 inputs. Two forms:
* INPUT=aes67 -> first available AES67 input.
* INPUT=aes67,<substr> -> case-insensitive substring match against
the device name, e.g. INPUT=aes67,8f6326.
- BROADCAST_NAME: Name of the broadcast (Auracast BIG name).
Default: "Broadcast0".
- PROGRAM_INFO: Free-text program/broadcast info.
Default: "Some Announcements".
- LANGUATE: ISO 639-3 language code used by config (intentional key name).
Default: "deu".
- AURACAST_SD_BLOCKSIZE: Hint for PortAudio/PipeWire block size in frames.
Default: 128.
- AURACAST_SD_LATENCY: PortAudio latency hint in seconds.
Default: 0.0027 (~128/48000 s).
- PULSE_LATENCY_MSEC: Pulse/PipeWire latency hint in milliseconds.
Default: 1.
- PIPEWIRE_LATENCY: PipeWire latency hint in the form "<frames>/<rate>".
Default is initially set to "128/48000" and then overwritten at runtime to
"128/<CAPTURE_SRATE>" based on the capture rate used by this script.
Examples (.env)
---------------
LOG_LEVEL=DEBUG
INPUT=aes67,8f6326
BROADCAST_NAME=MyBroadcast
PROGRAM_INFO="Live announcements"
LANGUATE=deu
"""
import logging
import os
import time
from dotenv import load_dotenv
from auracast import multicast
from auracast import auracast_config
@@ -22,27 +73,49 @@ if __name__ == "__main__":
os.environ.setdefault("AURACAST_SD_LATENCY", "0.0027") # ~128/48000 s
os.environ.setdefault("PULSE_LATENCY_MSEC", "1")
os.environ.setdefault("PIPEWIRE_LATENCY", "128/48000")
logging.info("USB pw inputs:")
usb_inputs = list_usb_pw_inputs()
logging.info("AEs67 pw inputs:")
aes67_inputs = list_network_pw_inputs()
logging.info("USB pw inputs:")
for i, d in usb_inputs:
logging.info(f"{i}: {d['name']} in={d['max_input_channels']}")
aes67_inputs = list_network_pw_inputs()
logging.info("AES67 pw inputs:")
for i, d in aes67_inputs:
logging.info(f"{i}: {d['name']} in={d['max_input_channels']}")
# Input selection (usb | network). Default to usb.
input_mode = (os.environ.get('INPUT', 'usb') or 'usb').lower()
if input_mode == 'network':
if aes67_inputs:
input_sel = aes67_inputs[0][0]
# Input selection (usb | aes67). Default to usb.
# Allows specifying an AES67 device by substring: INPUT=aes67,<substring>
# Example: INPUT=aes67,8f6326 will match a device name containing "8f6326".
input_env = os.environ.get('INPUT', 'usb') or 'usb'
parts = [p.strip() for p in input_env.split(',', 1)]
input_mode = (parts[0] or 'usb').lower()
iface_substr = (parts[1].lower() if len(parts) > 1 and parts[1] else None)
if input_mode == 'aes67':
if not aes67_inputs and not iface_substr:
# No AES67 inputs and no specific target -> fail fast
raise RuntimeError("No AES67 audio inputs found.")
if iface_substr:
# Loop until a matching AES67 input becomes available
while True:
current = list_network_pw_inputs()
sel = next(((i, d) for i, d in current if iface_substr in (d.get('name','').lower())), None)
if sel:
input_sel = sel[0]
logging.info(f"Selected AES67 input by match '{iface_substr}': index={input_sel}")
break
logging.info(f"Waiting for AES67 input matching '{iface_substr}'... retrying in 2s")
time.sleep(2)
else:
raise RuntimeError("No audio inputs found (USB or network).")
input_sel = aes67_inputs[0][0]
logging.info(f"Selected first AES67 input: index={input_sel}, device={aes67_inputs[0][1]['name']}")
else:
if usb_inputs:
input_sel = usb_inputs[0][0]
logging.info(f"Selected first USB input: index={input_sel}, device={usb_inputs[0][1]['name']}")
else:
raise RuntimeError("No audio inputs found (USB or network).")
raise RuntimeError("No USB audio inputs found.")
TRANSPORT1 = 'serial:/dev/ttyAMA3,1000000,rtscts' # transport for raspberry pi gpio header
TRANSPORT2 = 'serial:/dev/ttyAMA4,1000000,rtscts' # transport for raspberry pi gpio header

View File

@@ -25,6 +25,16 @@ def _pa_like_hostapi_index():
def _pw_dump():
return json.loads(subprocess.check_output(["pw-dump"]))
def _sd_refresh():
"""Force PortAudio to re-enumerate devices on next query.
sounddevice/PortAudio keeps a static device list after initialization.
Terminating here ensures that subsequent sd.query_* calls re-initialize
and see newly added devices (e.g., AES67 nodes created after start).
"""
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 = []
@@ -41,6 +51,8 @@ def list_usb_pw_inputs():
Return [(device_index, device_dict), ...] for PipeWire **input** nodes
backed by **USB** devices (excludes monitor sources).
"""
# Refresh PortAudio so we see newly added nodes before mapping
_sd_refresh()
pa_idx = _pa_like_hostapi_index()
pw = _pw_dump()
@@ -77,6 +89,8 @@ def list_network_pw_inputs():
Return [(device_index, device_dict), ...] for PipeWire **input** nodes that
look like network/AES67/RTP sources (excludes monitor sources).
"""
# Refresh PortAudio so we see newly added nodes before mapping
_sd_refresh()
pa_idx = _pa_like_hostapi_index()
pw = _pw_dump()