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

@@ -170,11 +170,29 @@ if audio_mode == "Demo":
"6 × 16kHz": {"quality": "Fair (16kHz)", "streams": 6},
}
demo_options = list(demo_stream_map.keys())
default_demo = demo_options[0]
default_index = 0
saved_type = saved_settings.get('demo_stream_type')
if isinstance(saved_type, str) and saved_type in demo_options:
default_index = demo_options.index(saved_type)
else:
saved_rate = saved_settings.get('auracast_sampling_rate_hz')
saved_total = saved_settings.get('demo_total_streams')
if saved_total is None and saved_settings.get('audio_mode') == 'Demo':
saved_total = len(saved_settings.get('channel_names') or [])
try:
if saved_rate and saved_total:
for i, label in enumerate(demo_options):
cfg = demo_stream_map[label]
rate_for_label = QUALITY_MAP[cfg['quality']]['rate']
if cfg['streams'] == int(saved_total) and int(saved_rate) == rate_for_label:
default_index = i
break
except Exception:
default_index = 0
demo_selected = st.selectbox(
"Demo Stream Type",
demo_options,
index=0,
index=default_index,
help="Select the demo stream configuration."
)
# Stream password and flags (same as USB/AES67)

View File

@@ -19,8 +19,9 @@ from auracast import multicast_control, auracast_config
import sounddevice as sd # type: ignore
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
@@ -32,8 +33,6 @@ STREAM_SETTINGS_FILE = os.path.join(os.path.dirname(__file__), 'stream_settings.
TRANSPORT1 = os.getenv('TRANSPORT1', 'serial:/dev/ttyAMA3,1000000,rtscts') # transport for raspberry pi gpio header
TRANSPORT2 = os.getenv('TRANSPORT2', 'serial:/dev/ttyAMA4,1000000,rtscts') # transport for raspberry pi gpio header
os.environ["PULSE_LATENCY_MSEC"] = "3"
# In-memory cache to avoid disk I/O on hot paths like /status
@@ -105,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:
@@ -162,7 +161,16 @@ class StreamerWorker:
pass
self._multicaster1 = None
# overwrite some configurations
conf.transport = TRANSPORT1
# Enable adaptive frame dropping only for device-based inputs (not file/demo)
try:
conf.enable_adaptive_frame_dropping = any(
isinstance(big.audio_source, str) and big.audio_source.startswith('device:')
for big in conf.bigs
)
except Exception:
conf.enable_adaptive_frame_dropping = False
# Derive device name and input mode
first_source = conf.bigs[0].audio_source if conf.bigs else ''
input_device_name = None
@@ -170,21 +178,28 @@ 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()
audio_mode_persist = 'Network' 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:
if big.audio_source.startswith('device:'):
big.audio_source = f'device:{device_index}'
devinfo = sd.query_devices(device_index)
capture_rate = int(devinfo.get('default_samplerate') or 48000)
# Force capture at 48 kHz to avoid resampler latency and 44.1 kHz incompatibilities
capture_rate = 48000
max_in = int(devinfo.get('max_input_channels') or 1)
channels = max(1, min(2, max_in))
for big in conf.bigs:
@@ -204,6 +219,14 @@ class StreamerWorker:
auto_started = True
# Return proposed settings to persist on API side
demo_count = sum(1 for big in conf.bigs if isinstance(big.audio_source, str) and big.audio_source.startswith('file:'))
demo_rate = int(conf.auracast_sampling_rate_hz or 0)
demo_type = None
if demo_count > 0 and demo_rate > 0:
if demo_rate in (48000, 24000, 16000):
demo_type = f"{demo_count} × {demo_rate//1000}kHz"
else:
demo_type = f"{demo_count} × {demo_rate}Hz"
return {
'channel_names': [big.name for big in conf.bigs],
'languages': [big.language for big in conf.bigs],
@@ -218,6 +241,8 @@ class StreamerWorker:
'immediate_rendering': getattr(conf, 'immediate_rendering', False),
'assisted_listening_stream': getattr(conf, 'assisted_listening_stream', False),
'stream_password': (conf.bigs[0].code if conf.bigs and getattr(conf.bigs[0], 'code', None) else None),
'demo_total_streams': demo_count,
'demo_stream_type': demo_type,
'is_streaming': auto_started,
}
@@ -230,10 +255,27 @@ class StreamerWorker:
self._multicaster2 = None
conf.transport = TRANSPORT2
# Enable adaptive frame dropping only for device-based inputs (not file/demo)
try:
conf.enable_adaptive_frame_dropping = any(
isinstance(big.audio_source, str) and big.audio_source.startswith('device:')
for big in conf.bigs
)
except Exception:
conf.enable_adaptive_frame_dropping = False
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}'
@@ -313,6 +355,24 @@ async def initialize2(conf: auracast_config.AuracastConfigGroup):
try:
log.info('Initializing multicaster2 with config:\n %s', conf.model_dump_json(indent=2))
await streamer.call(streamer._w_init_secondary, conf)
try:
is_demo = any(isinstance(big.audio_source, str) and big.audio_source.startswith('file:') for big in conf.bigs)
if is_demo:
settings = load_stream_settings() or {}
primary_count = int(settings.get('demo_total_streams') or len(settings.get('channel_names') or []))
secondary_count = len(conf.bigs or [])
total = primary_count + secondary_count
settings['demo_total_streams'] = total
demo_rate = int(conf.auracast_sampling_rate_hz or 0)
if demo_rate > 0:
if demo_rate in (48000, 24000, 16000):
settings['demo_stream_type'] = f"{total} × {demo_rate//1000}kHz"
else:
settings['demo_stream_type'] = f"{total} × {demo_rate}Hz"
settings['timestamp'] = datetime.utcnow().isoformat()
save_stream_settings(settings)
except Exception:
log.warning("Failed to persist demo_total_streams in /init2", exc_info=True)
except Exception as e:
log.error("Exception in /init2: %s", traceback.format_exc())
raise HTTPException(status_code=500, detail=str(e))
@@ -415,7 +475,7 @@ async def _autostart_from_settings():
):
return
# Check against the cached device lists
usb = [d for _, d in get_usb_pw_inputs()]
usb = [d for _, d in get_alsa_usb_inputs()]
net = [d for _, d in get_network_pw_inputs()]
names = {d.get('name') for d in usb} | {d.get('name') for d in net}
if input_device_name in names:
@@ -471,11 +531,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: