Implement adaptive frame dropping (#10)

- Implement adaptive frame dropping to prevent latency from accumulating
- small packets are dropped and a crossfade is used to hide the dropping.
- still audible in some situations

Co-authored-by: pstruebi <struebin.patrick.com>
Reviewed-on: https://gitea.pstruebi.xyz/auracaster/bumble-auracast/pulls/10
This commit was merged in pull request #10.
This commit is contained in:
2025-11-04 17:16:33 +01:00
parent 5a1e1f13ac
commit 98dd00e653
7 changed files with 677 additions and 100 deletions

View File

@@ -5,19 +5,13 @@ multicast_script
Loads environment variables from a .env file located next to this script
and configures the multicast broadcast. Only UPPERCASE keys are read.
This version uses ALSA backend for USB audio devices (no PipeWire required).
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".
@@ -27,17 +21,13 @@ Environment variables
- LANGUATE: ISO 639-3 language code used by config (intentional key name).
Default: "deu".
- PULSE_LATENCY_MSEC: Pulse/PipeWire latency hint in milliseconds.
Default: 3.
Examples (.env)
---------------
LOG_LEVEL=DEBUG
INPUT=aes67,8f6326
BROADCAST_NAME=MyBroadcast
PROGRAM_INFO="Live announcements"
LANGUATE=deu
"""
LANGUATE=deu"""
import logging
import os
import time
@@ -45,9 +35,10 @@ from dotenv import load_dotenv
from auracast import multicast
from auracast import auracast_config
from auracast.utils.sounddevice_utils import (
get_usb_pw_inputs,
get_network_pw_inputs,
get_alsa_usb_inputs,
get_network_pw_inputs, # PipeWire network (AES67) inputs
refresh_pw_cache,
resolve_input_device_index,
)
@@ -60,65 +51,81 @@ 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')
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:")
# 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']}")
aes67_inputs = get_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 | 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)
# 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
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:
refresh_pw_cache()
current = get_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]
selected_dev = sel[1]
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:
input_sel, selected_dev = aes67_inputs[0]
logging.info(f"Selected first AES67 input: index={input_sel}, device={selected_dev['name']}")
else:
# Loop until a USB input becomes available (mirror AES67 retry behavior)
while True:
while True:
try:
refresh_pw_cache()
current = get_usb_pw_inputs()
if current:
input_sel, selected_dev = current[0]
logging.info(f"Selected first USB input: index={input_sel}, device={selected_dev['name']}")
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
logging.info("Waiting for USB input... retrying in 2s")
time.sleep(2)
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 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
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
@@ -150,14 +157,16 @@ if __name__ == "__main__":
),
#auracast_config.AuracastBigConfigEng(),
],
immediate_rendering=True,
immediate_rendering=False,
presentation_delay_us=40000,
qos_config=auracast_config.AuracastQosHigh(),
auracast_sampling_rate_hz = LC3_SRATE,
octets_per_frame = OCTETS_PER_FRAME,
transport=TRANSPORT1
transport=TRANSPORT1,
enable_adaptive_frame_dropping=True,
)
#config.debug = True
config.debug = False
logging.info(config.model_dump_json(indent=2))
multicast.run_async(