refractori to use one common radio_init for both primary and secondary radio

This commit is contained in:
2025-11-17 18:12:28 +01:00
parent 572eb4b600
commit 1b744d52d8
3 changed files with 209 additions and 227 deletions

View File

@@ -40,8 +40,6 @@ class AuracastGlobalConfig(BaseModel):
# so receivers may render earlier than the presentation delay for lower latency. # so receivers may render earlier than the presentation delay for lower latency.
immediate_rendering: bool = False immediate_rendering: bool = False
assisted_listening_stream: bool = False assisted_listening_stream: bool = False
# Adaptive frame dropping: discard sub-frame samples when buffer exceeds threshold
enable_adaptive_frame_dropping: bool = False
# "Audio input. " # "Audio input. "
# "'device' -> use the host's default sound input device, " # "'device' -> use the host's default sound input device, "

View File

@@ -885,9 +885,6 @@ if __name__ == "__main__":
#config.immediate_rendering = True #config.immediate_rendering = True
#config.debug = True #config.debug = True
config.enable_adaptive_frame_dropping=False
# Enable clock drift compensation to prevent latency accumulation
run_async( run_async(
broadcast( broadcast(
config, config,

View File

@@ -191,137 +191,53 @@ async def _stream_lc3(audio_data: dict[str, str], bigs_template: list) -> None:
multicaster1.big_conf = bigs_template multicaster1.big_conf = bigs_template
await multicaster1.start_streaming() await multicaster1.start_streaming()
@app.post("/init") async def init_radio(transport: str, conf: auracast_config.AuracastConfigGroup, current_mc: multicast_control.Multicaster | None):
async def initialize(conf: auracast_config.AuracastConfigGroup):
"""Initializes the primary broadcaster on the streamer thread."""
global global_config_group
async with _stream_lock:
try:
global_config_group = conf
log.info('Initializing multicaster1 with config:\n %s', conf.model_dump_json(indent=2))
# Inline of _init_primary
global multicaster1
if multicaster1 is not None:
await multicaster1.shutdown()
multicaster1 = None
conf.transport = TRANSPORT1
conf.enable_adaptive_frame_dropping = any(
isinstance(big.audio_source, str) and big.audio_source.startswith('device:')
for big in conf.bigs
)
first_source = conf.bigs[0].audio_source if conf.bigs else ''
input_device_name = None
audio_mode_persist = 'Demo'
if isinstance(first_source, str) and first_source.startswith('device:'):
input_device_name = first_source.split(':', 1)[1] if ':' in first_source else None
alsa_usb_names = {d.get('name') for _, d in get_alsa_usb_inputs()}
net_names = {d.get('name') for _, d in get_network_pw_inputs()}
audio_mode_persist = 'Network' if (input_device_name in net_names) else 'USB'
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 isinstance(big.audio_source, str) and big.audio_source.startswith('device:'):
big.audio_source = f'device:{device_index}'
devinfo = sd.query_devices(device_index)
max_in = int(devinfo.get('max_input_channels') or 1)
channels = max(1, min(2, max_in))
for big in conf.bigs:
big.input_format = f"int16le,{48000},{channels}"
conf.qos_config.max_transport_latency_ms = int(conf.qos_config.number_of_retransmissions) * 10 + 3
for big in conf.bigs:
big.random_address = gen_random_add()
multicaster1 = multicast_control.Multicaster(conf, conf.bigs)
await multicaster1.init_broadcast()
auto_started = False
if any(isinstance(big.audio_source, str) and (big.audio_source.startswith("device:") or big.audio_source.startswith("file:")) for big in conf.bigs):
await multicaster1.start_streaming()
auto_started = True
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"
persisted = {
'channel_names': [big.name for big in conf.bigs],
'languages': [big.language for big in conf.bigs],
'audio_mode': audio_mode_persist,
'input_device': input_device_name,
'program_info': [getattr(big, 'program_info', None) for big in conf.bigs],
'gain': [getattr(big, 'input_gain', 1.0) for big in conf.bigs],
'auracast_sampling_rate_hz': conf.auracast_sampling_rate_hz,
'octets_per_frame': conf.octets_per_frame,
'presentation_delay_us': getattr(conf, 'presentation_delay_us', None),
'rtn': getattr(getattr(conf, 'qos_config', None), 'number_of_retransmissions', None),
'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,
}
# Persist returned settings (avoid touching from worker thread)
save_settings(persisted, secondary=False)
except Exception as e:
log.error("Exception in /init: %s", traceback.format_exc())
raise HTTPException(status_code=500, detail=str(e))
@app.post("/init2")
async def initialize2(conf: auracast_config.AuracastConfigGroup):
"""Initializes the secondary broadcaster on the streamer thread."""
try: try:
log.info('Initializing multicaster2 with config:\n %s', conf.model_dump_json(indent=2)) log.info('Initializing multicaster with transport %s and config:\n %s', transport, conf.model_dump_json(indent=2))
# Inline of _init_secondary
global multicaster2
if multicaster2 is not None:
await multicaster2.shutdown()
multicaster2 = None
conf.transport = TRANSPORT2 if current_mc is not None:
conf.enable_adaptive_frame_dropping = any( await current_mc.shutdown()
isinstance(big.audio_source, str) and big.audio_source.startswith('device:') current_mc = None
for big in conf.bigs
)
conf.qos_config.max_transport_latency_ms = int(conf.qos_config.number_of_retransmissions) * 10 + 3
for big in conf.bigs: conf.transport = transport
if isinstance(big.audio_source, str) and big.audio_source.startswith('device:'):
device_name = big.audio_source.split(':', 1)[1]
net_names = {d.get('name') for _, d in get_network_pw_inputs()}
alsa_usb_names = {d.get('name') for _, d in get_alsa_usb_inputs()}
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}'
multicaster2 = multicast_control.Multicaster(conf, conf.bigs)
await multicaster2.init_broadcast()
if any(isinstance(big.audio_source, str) and (big.audio_source.startswith("device:") or big.audio_source.startswith("file:")) for big in conf.bigs):
await multicaster2.start_streaming()
# Build and persist secondary settings analogous to primary
first_source = conf.bigs[0].audio_source if conf.bigs else '' first_source = conf.bigs[0].audio_source if conf.bigs else ''
input_device_name = None input_device_name = None
audio_mode_persist = 'Demo' audio_mode_persist = 'Demo'
if isinstance(first_source, str) and first_source.startswith('device:'): if isinstance(first_source, str) and first_source.startswith('device:'):
input_device_name = first_source.split(':', 1)[1] if ':' in first_source else None input_device_name = first_source.split(':', 1)[1] if ':' in first_source else None
try: alsa_usb_names = {d.get('name') for _, d in get_alsa_usb_inputs()}
net_names = {d.get('name') for _, d in get_network_pw_inputs()} net_names = {d.get('name') for _, d in get_network_pw_inputs()}
except Exception:
net_names = set()
audio_mode_persist = 'Network' if (input_device_name in net_names) else 'USB' audio_mode_persist = 'Network' if (input_device_name in net_names) else 'USB'
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 isinstance(big.audio_source, str) and big.audio_source.startswith('device:'):
big.audio_source = f'device:{device_index}'
devinfo = sd.query_devices(device_index)
max_in = int(devinfo.get('max_input_channels') or 1)
channels = max(1, min(2, max_in))
for big in conf.bigs:
big.input_format = f"int16le,{48000},{channels}"
conf.qos_config.max_transport_latency_ms = int(conf.qos_config.number_of_retransmissions) * 10 + 3
for big in conf.bigs:
big.random_address = gen_random_add()
mc = multicast_control.Multicaster(conf, conf.bigs)
await mc.init_broadcast()
auto_started = False
if any(isinstance(big.audio_source, str) and (big.audio_source.startswith("device:") or big.audio_source.startswith("file:")) for big in conf.bigs):
await mc.start_streaming()
auto_started = True
demo_count = sum(1 for big in conf.bigs if isinstance(big.audio_source, str) and big.audio_source.startswith('file:')) 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_rate = int(conf.auracast_sampling_rate_hz or 0)
demo_type = None demo_type = None
@@ -330,7 +246,7 @@ async def initialize2(conf: auracast_config.AuracastConfigGroup):
demo_type = f"{demo_count} × {demo_rate//1000}kHz" demo_type = f"{demo_count} × {demo_rate//1000}kHz"
else: else:
demo_type = f"{demo_count} × {demo_rate}Hz" demo_type = f"{demo_count} × {demo_rate}Hz"
persisted2 = { persisted = {
'channel_names': [big.name for big in conf.bigs], 'channel_names': [big.name for big in conf.bigs],
'languages': [big.language for big in conf.bigs], 'languages': [big.language for big in conf.bigs],
'audio_mode': audio_mode_persist, 'audio_mode': audio_mode_persist,
@@ -346,28 +262,34 @@ async def initialize2(conf: auracast_config.AuracastConfigGroup):
'stream_password': (conf.bigs[0].code if conf.bigs and getattr(conf.bigs[0], 'code', None) else None), 'stream_password': (conf.bigs[0].code if conf.bigs and getattr(conf.bigs[0], 'code', None) else None),
'demo_total_streams': demo_count, 'demo_total_streams': demo_count,
'demo_stream_type': demo_type, 'demo_stream_type': demo_type,
'is_streaming': any(isinstance(big.audio_source, str) and (big.audio_source.startswith("device:") or big.audio_source.startswith("file:")) for big in conf.bigs), 'is_streaming': auto_started,
} }
save_settings(persisted2, secondary=True) return mc, persisted
is_demo = any(isinstance(big.audio_source, str) and big.audio_source.startswith('file:') for big in conf.bigs) except HTTPException:
if is_demo: raise
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 as e: except Exception as e:
log.error("Exception in /init2: %s", traceback.format_exc()) log.error("Exception in init_radio: %s", traceback.format_exc())
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@app.post("/init")
async def initialize(conf: auracast_config.AuracastConfigGroup):
"""Initializes the primary broadcaster on the streamer thread."""
async with _stream_lock:
global multicaster1, global_config_group
mc, persisted = await init_radio(TRANSPORT1, conf, multicaster1)
multicaster1 = mc
global_config_group = conf
save_settings(persisted, secondary=False)
@app.post("/init2")
async def initialize2(conf: auracast_config.AuracastConfigGroup):
"""Initializes the secondary broadcaster on the streamer thread."""
async with _stream_lock:
global multicaster2
mc, persisted = await init_radio(TRANSPORT2, conf, multicaster2)
multicaster2 = mc
save_settings(persisted, secondary=True)
@app.post("/stop_audio") @app.post("/stop_audio")
async def stop_audio(): async def stop_audio():
"""Stops streaming on both multicaster1 and multicaster2 (worker thread).""" """Stops streaming on both multicaster1 and multicaster2 (worker thread)."""
@@ -407,97 +329,162 @@ async def get_status():
return status return status
async def _autostart_from_settings(): async def _autostart_from_settings():
"""Background task: auto-start last selected device-based input at server startup. settings1 = load_stream_settings() or {}
settings2 = load_stream_settings2() or {}
Skips Webapp (webrtc) and Demo (file) modes. Polls every 2 seconds until the async def do_primary():
saved device name appears in either USB or Network lists, then builds a config global multicaster1, global_config_group
and initializes streaming. settings = settings1
""" audio_mode = settings.get('audio_mode')
settings = load_stream_settings() or {} input_device_name = settings.get('input_device')
audio_mode = settings.get('audio_mode') rate = settings.get('auracast_sampling_rate_hz')
input_device_name = settings.get('input_device') octets = settings.get('octets_per_frame')
rate = settings.get('auracast_sampling_rate_hz') pres_delay = settings.get('presentation_delay_us')
octets = settings.get('octets_per_frame') saved_rtn = settings.get('rtn')
pres_delay = settings.get('presentation_delay_us') immediate_rendering = settings.get('immediate_rendering', False)
saved_rtn = settings.get('rtn') assisted_listening_stream = settings.get('assisted_listening_stream', False)
immediate_rendering = settings.get('immediate_rendering', False) channel_names = settings.get('channel_names') or ["Broadcast0"]
assisted_listening_stream = settings.get('assisted_listening_stream', False) program_info = settings.get('program_info') or channel_names
channel_names = settings.get('channel_names') or ["Broadcast0"] languages = settings.get('languages') or ["deu"]
program_info = settings.get('program_info') or channel_names stream_password = settings.get('stream_password')
languages = settings.get('languages') or ["deu"] original_ts = settings.get('timestamp')
stream_password = settings.get('stream_password') previously_streaming = bool(settings.get('is_streaming'))
original_ts = settings.get('timestamp')
previously_streaming = bool(settings.get('is_streaming'))
# Only auto-start if the previous state was streaming and it's a device-based input. if not previously_streaming or not input_device_name or rate is None or octets is None:
if not previously_streaming: return
return
if not input_device_name:
return
if rate is None or octets is None:
# Not enough info to reconstruct stream reliably
return
# Avoid duplicate start if already streaming
current = await _status_primary()
if current.get('is_streaming'):
return
while True:
# Do not interfere if user started a stream manually in the meantime
current = await _status_primary() current = await _status_primary()
if current.get('is_streaming'): if current.get('is_streaming'):
return return
# Abort if saved settings changed to a different target while we were polling while True:
current_settings = load_stream_settings() or {} current = await _status_primary()
if current_settings.get('timestamp') != original_ts: if current.get('is_streaming'):
# Settings were updated (likely by user via /init)
# If the target device or mode changed, stop autostart
if (
current_settings.get('input_device') != input_device_name or
current_settings.get('audio_mode') != audio_mode
):
return return
# Check against the cached device lists current_settings = load_stream_settings() or {}
usb = [d for _, d in get_alsa_usb_inputs()] if current_settings.get('timestamp') != original_ts:
net = [d for _, d in get_network_pw_inputs()] if (
names = {d.get('name') for d in usb} | {d.get('name') for d in net} current_settings.get('input_device') != input_device_name or
if input_device_name in names: current_settings.get('audio_mode') != audio_mode
# Build a minimal config based on saved fields ):
bigs = [ return
auracast_config.AuracastBigConfig( usb = [d for _, d in get_alsa_usb_inputs()]
code=stream_password, net = [d for _, d in get_network_pw_inputs()]
name=channel_names[0] if channel_names else "Broadcast0", names = {d.get('name') for d in usb} | {d.get('name') for d in net}
program_info=program_info[0] if isinstance(program_info, list) and program_info else program_info, if input_device_name in names:
language=languages[0] if languages else "deu", bigs = [
audio_source=f"device:{input_device_name}", auracast_config.AuracastBigConfig(
# input_format is intentionally omitted to use the default code=stream_password,
iso_que_len=1, name=channel_names[0] if channel_names else "Broadcast0",
sampling_frequency=rate, program_info=program_info[0] if isinstance(program_info, list) and program_info else program_info,
language=languages[0] if languages else "deu",
audio_source=f"device:{input_device_name}",
iso_que_len=1,
sampling_frequency=rate,
octets_per_frame=octets,
)
]
conf = auracast_config.AuracastConfigGroup(
auracast_sampling_rate_hz=rate,
octets_per_frame=octets, octets_per_frame=octets,
transport=TRANSPORT1,
immediate_rendering=immediate_rendering,
assisted_listening_stream=assisted_listening_stream,
presentation_delay_us=pres_delay if pres_delay is not None else 40000,
bigs=bigs,
) )
] conf.qos_config = auracast_config.AuracastQoSConfig(
conf = auracast_config.AuracastConfigGroup( iso_int_multiple_10ms=1,
auracast_sampling_rate_hz=rate, number_of_retransmissions=int(saved_rtn),
octets_per_frame=octets, max_transport_latency_ms=int(saved_rtn) * 10 + 3,
transport=TRANSPORT1, )
immediate_rendering=immediate_rendering, await asyncio.sleep(2)
assisted_listening_stream=assisted_listening_stream, async with _stream_lock:
presentation_delay_us=pres_delay if pres_delay is not None else 40000, mc, persisted = await init_radio(TRANSPORT1, conf, multicaster1)
bigs=bigs, multicaster1 = mc
) global_config_group = conf
# Attach QoS if saved_rtn present save_settings(persisted, secondary=False)
conf.qos_config = auracast_config.AuracastQoSConfig( return
iso_int_multiple_10ms=1,
number_of_retransmissions=int(saved_rtn),
max_transport_latency_ms=int(saved_rtn) * 10 + 3,
)
# Initialize and start
await asyncio.sleep(2) await asyncio.sleep(2)
await initialize(conf)
async def do_secondary():
global multicaster2
settings = settings2
audio_mode = settings.get('audio_mode')
input_device_name = settings.get('input_device')
rate = settings.get('auracast_sampling_rate_hz')
octets = settings.get('octets_per_frame')
pres_delay = settings.get('presentation_delay_us')
saved_rtn = settings.get('rtn')
immediate_rendering = settings.get('immediate_rendering', False)
assisted_listening_stream = settings.get('assisted_listening_stream', False)
channel_names = settings.get('channel_names') or ["Broadcast0"]
program_info = settings.get('program_info') or channel_names
languages = settings.get('languages') or ["deu"]
stream_password = settings.get('stream_password')
original_ts = settings.get('timestamp')
previously_streaming = bool(settings.get('is_streaming'))
if not previously_streaming or not input_device_name or rate is None or octets is None:
return return
await asyncio.sleep(2) if multicaster2 is not None:
try:
if multicaster2.get_status().get('is_streaming'):
return
except Exception:
pass
while True:
if multicaster2 is not None:
try:
if multicaster2.get_status().get('is_streaming'):
return
except Exception:
pass
current_settings = load_stream_settings2() or {}
if current_settings.get('timestamp') != original_ts:
if (
current_settings.get('input_device') != input_device_name or
current_settings.get('audio_mode') != audio_mode
):
return
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:
bigs = [
auracast_config.AuracastBigConfig(
code=stream_password,
name=channel_names[0] if channel_names else "Broadcast0",
program_info=program_info[0] if isinstance(program_info, list) and program_info else program_info,
language=languages[0] if languages else "deu",
audio_source=f"device:{input_device_name}",
iso_que_len=1,
sampling_frequency=rate,
octets_per_frame=octets,
)
]
conf = auracast_config.AuracastConfigGroup(
auracast_sampling_rate_hz=rate,
octets_per_frame=octets,
transport=TRANSPORT2,
immediate_rendering=immediate_rendering,
assisted_listening_stream=assisted_listening_stream,
presentation_delay_us=pres_delay if pres_delay is not None else 40000,
bigs=bigs,
)
conf.qos_config = auracast_config.AuracastQoSConfig(
iso_int_multiple_10ms=1,
number_of_retransmissions=int(saved_rtn),
max_transport_latency_ms=int(saved_rtn) * 10 + 3,
)
await asyncio.sleep(2)
async with _stream_lock:
mc, persisted = await init_radio(TRANSPORT2, conf, multicaster2)
multicaster2 = mc
save_settings(persisted, secondary=True)
return
await asyncio.sleep(2)
await do_primary()
await do_secondary()
@app.on_event("startup") @app.on_event("startup")
async def _startup_autostart_event(): async def _startup_autostart_event():