12 Commits

15 changed files with 711 additions and 176 deletions
+6
View File
@@ -53,3 +53,9 @@ src/scripts/temperature_log*
src/auracast/server/recordings/
src/auracast/server/led_settings.json
# Dante license files
*.lic
src/dep/dante_package/dante_data/activation/device.lic
src/dep/dante_package/dante_data/activation/manufacturer.cert
+6 -2
View File
@@ -206,7 +206,7 @@ class PyAlsaAudioInput(audio_io.ThreadedAudioInput):
length, data = self._pcm.read_sw(frame_size + self._bang_bang)
avail = self._pcm.avail()
SETPOINT = 120
TOLERANCE = 40
TOLERANCE = 80
if avail < SETPOINT - TOLERANCE:
self._bang_bang = -1
elif avail > SETPOINT + TOLERANCE:
@@ -811,7 +811,11 @@ class Streamer():
if input_format == 'auto':
raise ValueError('input format details required for alsa input')
pcm = audio_io.PcmFormat.from_str(input_format)
audio_input = AlsaArecordAudioInput(audio_source[5:], pcm)
device_name = audio_source[5:]
if device_name.startswith('dante_'):
audio_input = PyAlsaAudioInput(device_name, pcm)
else:
audio_input = AlsaArecordAudioInput(device_name, pcm)
else:
audio_input = await audio_io.create_audio_input(audio_source, input_format)
# Store early so stop_streaming can close even if open() fails
+287 -98
View File
@@ -71,6 +71,10 @@ if not is_pw_disabled():
with st.form("signin_form"):
pw = st.text_input("Password", type="password")
submitted = st.form_submit_button("Sign in")
st.components.v1.html(
"<script>setTimeout(()=>window.parent.document.querySelector('input[type=\"password\"]')?.focus(),100)</script>",
height=0
)
if submitted:
if verify_password(pw, pw_rec):
st.session_state['frontend_authenticated'] = True
@@ -433,13 +437,43 @@ else:
disabled=is_streaming
)
# Use analog-specific defaults (not from saved settings which may have Dante values)
default_name = "Analog_Radio_1"
default_program_info = "Analog Radio Broadcast"
default_lang = "deu"
quality_options = list(QUALITY_MAP.keys())
default_quality = "Medium (24kHz)" if "Medium (24kHz)" in quality_options else quality_options[0]
# Use saved settings if audio_mode matches, otherwise use analog-specific defaults
saved_audio_mode = saved_settings.get('audio_mode')
if saved_audio_mode == 'Analog':
default_name = saved_settings.get('channel_names', ["Analog_Radio_1"])[0]
raw_program_info = saved_settings.get('program_info', default_name)
if isinstance(raw_program_info, list) and raw_program_info:
default_program_info = raw_program_info[0]
else:
default_program_info = raw_program_info
default_lang = saved_settings.get('languages', ["deu"])[0]
# Map saved sampling rate to quality label
saved_rate = saved_settings.get('auracast_sampling_rate_hz')
if saved_rate == 48000:
default_quality = "High (48kHz)"
elif saved_rate == 32000:
default_quality = "Good (32kHz)"
elif saved_rate == 24000:
default_quality = "Medium (24kHz)"
elif saved_rate == 16000:
default_quality = "Fair (16kHz)"
else:
default_quality = "Medium (24kHz)"
saved_pwd = saved_settings.get('stream_password', '')
else:
# Use analog-specific defaults when switching from another mode
default_name = "Analog_Radio_1"
default_program_info = "Analog Radio Broadcast"
default_lang = "deu"
default_quality = "Medium (24kHz)" if "Medium (24kHz)" in quality_options else quality_options[0]
saved_pwd = ''
if default_quality not in quality_options:
default_quality = quality_options[0]
quality1 = st.selectbox(
"Stream Quality (Radio 1)",
quality_options,
@@ -450,7 +484,7 @@ else:
stream_passwort1 = st.text_input(
"Stream Passwort (Radio 1)",
value="",
value=saved_pwd,
type="password",
disabled=is_streaming,
help="Optional: Set a broadcast code for Radio 1."
@@ -557,7 +591,10 @@ else:
input_device1 = None
else:
# Mono mode: show all available channels
saved_input_device = saved_settings.get('input_device')
default_r1_idx = 0
if saved_input_device in analog_names:
default_r1_idx = analog_names.index(saved_input_device)
input_device1 = st.selectbox(
"Input Device (Radio 1)",
analog_names,
@@ -606,22 +643,53 @@ else:
)
if radio2_enabled and not stereo_enabled:
# Use analog-specific defaults for Radio 2
default_name_r2 = "Analog_Radio_2"
default_program_info_r2 = "Analog Radio Broadcast"
default_lang_r2 = "deu"
# Use saved settings if audio_mode matches, otherwise use analog-specific defaults for Radio 2
secondary_settings = saved_settings.get('secondary', {})
saved_audio_mode = saved_settings.get('audio_mode')
if saved_audio_mode == 'Analog' and secondary_settings:
default_name_r2 = secondary_settings.get('channel_names', ["Analog_Radio_2"])[0] if isinstance(secondary_settings.get('channel_names'), list) else secondary_settings.get('channel_names', "Analog_Radio_2")
raw_program_info_r2 = secondary_settings.get('program_info', default_name_r2)
if isinstance(raw_program_info_r2, list) and raw_program_info_r2:
default_program_info_r2 = raw_program_info_r2[0]
else:
default_program_info_r2 = raw_program_info_r2
default_lang_r2 = secondary_settings.get('languages', ["deu"])[0] if isinstance(secondary_settings.get('languages'), list) else secondary_settings.get('languages', 'deu')
# Map saved sampling rate to quality label
saved_rate_r2 = secondary_settings.get('auracast_sampling_rate_hz')
if saved_rate_r2 == 48000:
default_quality_r2 = "High (48kHz)"
elif saved_rate_r2 == 32000:
default_quality_r2 = "Good (32kHz)"
elif saved_rate_r2 == 24000:
default_quality_r2 = "Medium (24kHz)"
elif saved_rate_r2 == 16000:
default_quality_r2 = "Fair (16kHz)"
else:
default_quality_r2 = "Medium (24kHz)"
saved_pwd_r2 = secondary_settings.get('stream_password', '')
else:
# Use analog-specific defaults when switching from another mode
default_name_r2 = "Analog_Radio_2"
default_program_info_r2 = "Analog Radio Broadcast"
default_lang_r2 = "deu"
default_quality_r2 = "Medium (24kHz)" if "Medium (24kHz)" in quality_options else quality_options[0]
saved_pwd_r2 = ''
if default_quality_r2 not in quality_options:
default_quality_r2 = quality_options[0]
quality2 = st.selectbox(
"Stream Quality (Radio 2)",
quality_options,
index=quality_options.index(default_quality),
index=quality_options.index(default_quality_r2),
disabled=is_streaming,
help="Select the audio sampling rate for Radio 2."
)
stream_passwort2 = st.text_input(
"Stream Passwort (Radio 2)",
value="",
value=saved_pwd_r2,
type="password",
disabled=is_streaming,
help="Optional: Set a broadcast code for Radio 2."
@@ -682,7 +750,11 @@ else:
if not is_streaming:
if analog_names:
secondary_settings = saved_settings.get('secondary', {})
saved_input_device2 = secondary_settings.get('input_device')
default_r2_idx = 1 if len(analog_names) > 1 else 0
if saved_input_device2 in analog_names:
default_r2_idx = analog_names.index(saved_input_device2)
input_device2 = st.selectbox(
"Input Device (Radio 2)",
analog_names,
@@ -774,10 +846,15 @@ else:
)
# Dante stereo mode toggle
saved_r1_config = saved_settings.get('dante_radio1', {})
saved_audio_mode = saved_settings.get('audio_mode')
dante_stereo_enabled = False
if saved_audio_mode == 'Network - Dante':
# Check if any input device starts with dante_stereo_ to detect stereo mode
input_device = saved_settings.get('input_device', '')
dante_stereo_enabled = input_device.startswith('dante_stereo_')
dante_stereo_enabled = st.checkbox(
"🎧 Stereo Mode",
value=bool(saved_r1_config.get('dante_stereo_mode', False)),
value=dante_stereo_enabled,
help="Enable stereo streaming for Dante inputs. Select left and right channels from ASRC channels 1-6. Radio 2 and multi-stream configurations will be disabled in stereo mode.",
disabled=is_streaming
)
@@ -786,13 +863,23 @@ else:
dante_left_channel = None
dante_right_channel = None
if dante_stereo_enabled:
dante_channel_options = ["dante_asrc_ch1", "dante_asrc_ch2", "dante_asrc_ch3",
dante_channel_options = ["dante_asrc_ch1", "dante_asrc_ch2", "dante_asrc_ch3",
"dante_asrc_ch4", "dante_asrc_ch5", "dante_asrc_ch6"]
dante_channel_labels = ["CH1", "CH2", "CH3", "CH4", "CH5", "CH6"]
# Parse saved stereo device name to extract left and right channels
input_device = saved_settings.get('input_device', '')
saved_left = 'dante_asrc_ch1'
saved_right = 'dante_asrc_ch2'
if input_device.startswith('dante_stereo_'):
# Format: dante_stereo_<left>_<right>
parts = input_device.split('_')
if len(parts) >= 4:
saved_left = f"dante_asrc_ch{parts[2]}"
saved_right = f"dante_asrc_ch{parts[3]}"
col_left, col_right = st.columns(2)
with col_left:
saved_left = saved_r1_config.get('dante_stereo_left', 'dante_asrc_ch1')
left_idx = dante_channel_options.index(saved_left) if saved_left in dante_channel_options else 0
dante_left_channel = st.selectbox(
"Left Channel",
@@ -803,7 +890,6 @@ else:
help="Select the Dante ASRC channel for the left stereo channel"
)
with col_right:
saved_right = saved_r1_config.get('dante_stereo_right', 'dante_asrc_ch2')
right_idx = dante_channel_options.index(saved_right) if saved_right in dante_channel_options else 1
dante_right_channel = st.selectbox(
"Right Channel",
@@ -813,7 +899,7 @@ else:
disabled=is_streaming,
help="Select the Dante ASRC channel for the right stereo channel"
)
if dante_left_channel == dante_right_channel:
st.warning("⚠️ Left and right channels are the same. Select different channels for true stereo.")
else:
@@ -821,7 +907,22 @@ else:
# Stream count dropdown for Radio 1 (disabled in stereo mode - forced to 1 stream at 48kHz)
r1_stream_options = list(dante_stream_options.keys())
saved_r1_streams = saved_r1_config.get('stream_config', '1x48')
# Infer stream configuration from saved sampling rate
saved_rate = saved_settings.get('auracast_sampling_rate_hz')
saved_r1_streams = '1 × 48kHz' # default
if saved_rate:
if saved_rate == 48000:
channel_names = saved_settings.get('channel_names', [])
if len(channel_names) == 2:
saved_r1_streams = '2 × 24kHz'
elif len(channel_names) == 3:
saved_r1_streams = '3 × 16kHz'
else:
saved_r1_streams = '1 × 48kHz'
elif saved_rate == 24000:
saved_r1_streams = '2 × 24kHz'
elif saved_rate == 16000:
saved_r1_streams = '3 × 16kHz'
default_r1_idx = r1_stream_options.index(saved_r1_streams) if saved_r1_streams in r1_stream_options else 0
if dante_stereo_enabled:
@@ -851,15 +952,25 @@ else:
r1_available_qualities = []
for quality in ["High (48kHz)", "Good (32kHz)", "Medium (24kHz)", "Fair (16kHz)"]:
# Check if this quality is equal to or lower than the max
if (r1_max_quality == "High (48kHz)" or
if (r1_max_quality == "High (48kHz)" or
(r1_max_quality == "Medium (24kHz)" and quality in ["Medium (24kHz)", "Fair (16kHz)"]) or
(r1_max_quality == "Fair (16kHz)" and quality == "Fair (16kHz)")):
r1_available_qualities.append(quality)
saved_r1_quality = saved_r1_config.get('radio_quality', r1_max_quality)
# Map saved sampling rate to quality label
saved_r1_quality = r1_max_quality
saved_rate = saved_settings.get('auracast_sampling_rate_hz')
if saved_rate == 48000:
saved_r1_quality = "High (48kHz)"
elif saved_rate == 32000:
saved_r1_quality = "Good (32kHz)"
elif saved_rate == 24000:
saved_r1_quality = "Medium (24kHz)"
elif saved_rate == 16000:
saved_r1_quality = "Fair (16kHz)"
if saved_r1_quality not in r1_available_qualities:
saved_r1_quality = r1_max_quality
r1_radio_quality = st.selectbox(
"Stream Quality (Radio 1)",
r1_available_qualities,
@@ -867,29 +978,29 @@ else:
disabled=is_streaming,
help=f"Select stream quality for Radio 1. Maximum quality based on configuration: {r1_max_quality}"
)
# Radio-level settings for Radio 1
# First row: Assistive listening, immediate rendering, presentation delay, QoS
col_r1_flags1, col_r1_flags2, col_r1_pdelay, col_r1_qos = st.columns([1, 1, 0.7, 0.6], gap="small")
with col_r1_flags1:
r1_assisted_listening = st.checkbox(
"Assistive (R1)",
value=bool(saved_r1_config.get('assisted_listening', False)),
value=bool(saved_settings.get('assisted_listening_stream', False)),
disabled=is_streaming,
help="Assistive listening stream"
)
with col_r1_flags2:
r1_immediate_rendering = st.checkbox(
"Immediate (R1)",
value=bool(saved_r1_config.get('immediate_rendering', False)),
value=bool(saved_settings.get('immediate_rendering', False)),
disabled=is_streaming,
help="Ignore presentation delay"
)
with col_r1_pdelay:
default_pdelay = int(saved_r1_config.get('presentation_delay_us', 40000) or 40000)
default_pdelay = int(saved_settings.get('presentation_delay_us', 40000) or 40000)
default_pdelay_ms = max(10, min(200, default_pdelay // 1000))
r1_presentation_delay_ms = st.number_input(
"Delay (ms, R1)",
@@ -897,10 +1008,10 @@ else:
disabled=is_streaming,
help="Presentation delay for Radio 1"
)
with col_r1_qos:
qos_options = list(QOS_PRESET_MAP.keys())
saved_qos = saved_r1_config.get('qos_preset', 'Fast')
saved_qos = saved_settings.get('qos_preset', 'Fast')
default_qos_idx = qos_options.index(saved_qos) if saved_qos in qos_options else 0
r1_qos_preset = st.selectbox(
"QoS (R1)", options=qos_options, index=default_qos_idx,
@@ -918,24 +1029,31 @@ else:
if dante_stereo_enabled:
# Stereo mode: single stream with combined L+R channels
with st.expander("Stereo Stream - Radio 1", expanded=True):
saved_streams = saved_r1_config.get('streams', [])
saved_stream = saved_streams[0] if saved_streams else {}
# Read from flat settings structure
channel_names = saved_settings.get('channel_names', [])
program_infos = saved_settings.get('program_info', [])
languages = saved_settings.get('languages', [])
saved_name = channel_names[0] if channel_names else 'Dante_Stereo'
saved_program_info = program_infos[0] if program_infos else saved_name
saved_language = languages[0] if languages else 'eng'
saved_password = saved_settings.get('stream_password', '')
# First row: Channel name and password
col_name, col_pwd = st.columns([2, 1])
with col_name:
stream_name = st.text_input(
"Channel Name",
value=saved_stream.get('name', 'Dante_Stereo'),
value=saved_name,
disabled=is_streaming,
key="r1_stereo_name"
)
with col_pwd:
stream_password = st.text_input(
"Stream Password",
value=saved_stream.get('stream_password', ''),
value=saved_password,
type="password",
disabled=is_streaming,
key="r1_stereo_password",
@@ -944,19 +1062,19 @@ else:
# Second row: Program info and language
col_prog, col_lang_code = st.columns([2, 1])
with col_prog:
program_info = st.text_input(
"Program Info",
value=saved_stream.get('program_info', 'Dante Stereo Broadcast'),
value=saved_program_info,
disabled=is_streaming,
key="r1_stereo_program"
)
with col_lang_code:
language = st.text_input(
"Language",
value=saved_stream.get('language', 'eng'),
value=saved_language,
disabled=is_streaming,
key="r1_stereo_lang",
help="ISO 639-3 language code"
@@ -990,47 +1108,58 @@ else:
})
else:
# Normal mono mode: multiple streams with individual channels
# Read from flat settings structure
channel_names = saved_settings.get('channel_names', [])
program_infos = saved_settings.get('program_info', [])
languages = saved_settings.get('languages', [])
input_devices = saved_settings.get('input_devices', [])
stream_passwords = saved_settings.get('stream_passwords', []) if 'stream_passwords' in saved_settings else []
for i in range(r1_num_streams):
with st.expander(f"Stream {i+1} - Radio 1", expanded=True):
saved_streams = saved_r1_config.get('streams', [])
saved_stream = saved_streams[i] if i < len(saved_streams) else {}
# Get saved values from flat structure
saved_name = channel_names[i] if i < len(channel_names) else f'Dante_R1_S{i+1}'
saved_program_info = program_infos[i] if i < len(program_infos) else f'Dante Radio 1 Stream {i+1}'
saved_language = languages[i] if i < len(languages) else 'eng'
saved_password = stream_passwords[i] if i < len(stream_passwords) else ''
saved_input_device = input_devices[i] if i < len(input_devices) else None
# First row: Channel name and language
col_name, col_lang = st.columns([2, 1])
with col_name:
stream_name = st.text_input(
f"Channel Name",
value=saved_stream.get('name', f'Dante_R1_S{i+1}'),
value=saved_name,
disabled=is_streaming,
key=f"r1_stream_{i}_name"
)
with col_lang:
stream_password = st.text_input(
f"Stream Password",
value=saved_stream.get('stream_password', ''),
value=saved_password,
type="password",
disabled=is_streaming,
key=f"r1_stream_{i}_password",
help="Optional: Set a broadcast code for this stream"
)
# Second row: Program info and language
col_prog, col_lang_code = st.columns([2, 1])
with col_prog:
program_info = st.text_input(
f"Program Info",
value=saved_stream.get('program_info', f'Dante Radio 1 Stream {i+1}'),
value=saved_program_info,
disabled=is_streaming,
key=f"r1_stream_{i}_program"
)
with col_lang_code:
language = st.text_input(
f"Language",
value=saved_stream.get('language', 'eng'),
value=saved_language,
disabled=is_streaming,
key=f"r1_stream_{i}_lang",
help="ISO 639-3 language code"
@@ -1042,10 +1171,10 @@ else:
with col_device:
# Session state key for persisting the selection
device_session_key = f"r1_stream_{i}_device_saved"
if not is_streaming and input_options:
# Get default from session state first, then from saved settings
default_input_name = st.session_state.get(device_session_key, saved_stream.get('input_device'))
default_input_name = st.session_state.get(device_session_key, saved_input_device)
default_input_label = None
for label, name in option_name_map.items():
if name == default_input_name:
@@ -1053,7 +1182,7 @@ else:
break
if default_input_label not in input_options and input_options:
default_input_label = input_options[0]
selected_option = st.selectbox(
f"Input Device",
input_options,
@@ -1066,7 +1195,7 @@ else:
st.session_state[device_session_key] = input_device
else:
# When streaming, get the device from session state
current_device = st.session_state.get(device_session_key, saved_stream.get('input_device', 'No device'))
current_device = st.session_state.get(device_session_key, saved_input_device or 'No device')
# Convert internal name to display label
display_label = current_device
@@ -1095,26 +1224,45 @@ else:
# --- Radio 2 Section ---
with st.container(border=True):
st.subheader("Radio 2")
# Disable Radio 2 in stereo mode
saved_r2_config = saved_settings.get('dante_radio2', {})
secondary_settings = saved_settings.get('secondary', {})
if dante_stereo_enabled:
st.info("🎧 Radio 2 is automatically disabled in stereo mode")
radio2_enabled = False
else:
# Enable/disable checkbox for Radio 2
# Use saved settings or streaming state to determine default
radio2_enabled_default = secondary_is_streaming
# Check if secondary radio has saved settings (indicates it was enabled)
if secondary_settings.get('auracast_sampling_rate_hz') or secondary_settings.get('channel_names'):
radio2_enabled_default = True
radio2_enabled = st.checkbox(
"Enable Radio 2",
value=radio2_enabled_default,
disabled=is_streaming,
help="Activate a second Dante radio with its own quality and timing settings."
)
if radio2_enabled:
# Stream count dropdown for Radio 2
r2_stream_options = r1_stream_options
saved_r2_streams = saved_r2_config.get('stream_config', '1x48')
# Infer stream configuration from saved secondary sampling rate
saved_rate2 = secondary_settings.get('auracast_sampling_rate_hz')
saved_r2_streams = '1 × 48kHz' # default
if saved_rate2:
if saved_rate2 == 48000:
channel_names2 = secondary_settings.get('channel_names', [])
if len(channel_names2) == 2:
saved_r2_streams = '2 × 24kHz'
elif len(channel_names2) == 3:
saved_r2_streams = '3 × 16kHz'
else:
saved_r2_streams = '1 × 48kHz'
elif saved_rate2 == 24000:
saved_r2_streams = '2 × 24kHz'
elif saved_rate2 == 16000:
saved_r2_streams = '3 × 16kHz'
default_r2_idx = r2_stream_options.index(saved_r2_streams) if saved_r2_streams in r2_stream_options else 0
r2_stream_config = st.selectbox(
@@ -1136,11 +1284,20 @@ else:
(r2_max_quality == "Medium (24kHz)" and quality in ["Medium (24kHz)", "Fair (16kHz)"]) or
(r2_max_quality == "Fair (16kHz)" and quality == "Fair (16kHz)")):
r2_available_qualities.append(quality)
saved_r2_quality = saved_r2_config.get('radio_quality', r2_max_quality)
# Map saved secondary sampling rate to quality label
saved_r2_quality = r2_max_quality
if saved_rate2 == 48000:
saved_r2_quality = "High (48kHz)"
elif saved_rate2 == 32000:
saved_r2_quality = "Good (32kHz)"
elif saved_rate2 == 24000:
saved_r2_quality = "Medium (24kHz)"
elif saved_rate2 == 16000:
saved_r2_quality = "Fair (16kHz)"
if saved_r2_quality not in r2_available_qualities:
saved_r2_quality = r2_max_quality
r2_radio_quality = st.selectbox(
"Stream Quality (Radio 2)",
r2_available_qualities,
@@ -1148,29 +1305,28 @@ else:
disabled=is_streaming,
help=f"Select stream quality for Radio 2. Maximum quality based on configuration: {r2_max_quality}"
)
# Radio-level settings for Radio 2
# First row: Assistive listening, immediate rendering, presentation delay, QoS
col_r2_flags1, col_r2_flags2, col_r2_pdelay, col_r2_qos = st.columns([1, 1, 0.7, 0.6], gap="small")
with col_r2_flags1:
r2_assisted_listening = st.checkbox(
"Assistive (R2)",
value=bool(saved_r2_config.get('assisted_listening', False)),
value=bool(secondary_settings.get('assisted_listening_stream', False)),
disabled=is_streaming,
help="Assistive listening stream"
)
with col_r2_flags2:
r2_immediate_rendering = st.checkbox(
"Immediate (R2)",
value=bool(saved_r2_config.get('immediate_rendering', False)),
value=bool(secondary_settings.get('immediate_rendering', False)),
disabled=is_streaming,
help="Ignore presentation delay"
)
with col_r2_pdelay:
default_pdelay = int(saved_r2_config.get('presentation_delay_us', 40000) or 40000)
default_pdelay = int(secondary_settings.get('presentation_delay_us', 40000) or 40000)
default_pdelay_ms = max(10, min(200, default_pdelay // 1000))
r2_presentation_delay_ms = st.number_input(
"Delay (ms, R2)",
@@ -1178,13 +1334,13 @@ else:
disabled=is_streaming,
help="Presentation delay for Radio 2"
)
with col_r2_qos:
qos_options = list(QOS_PRESET_MAP.keys())
saved_qos = saved_r2_config.get('qos_preset', 'Fast')
default_qos_idx = qos_options.index(saved_qos) if saved_qos in qos_options else 0
saved_qos = secondary_settings.get('qos_preset', 'Fast')
default_qos_idx2 = qos_options.index(saved_qos) if saved_qos in qos_options else 0
r2_qos_preset = st.selectbox(
"QoS (R2)", options=qos_options, index=default_qos_idx,
"QoS (R2)", options=qos_options, index=default_qos_idx2,
disabled=is_streaming,
help="Quality of Service preset for Radio 2"
)
@@ -1192,48 +1348,59 @@ else:
# Per-stream configuration for Radio 2
st.write("**Stream Configuration (Radio 2)**")
r2_streams = []
# Read from flat secondary settings structure
channel_names2 = secondary_settings.get('channel_names', [])
program_infos2 = secondary_settings.get('program_info', [])
languages2 = secondary_settings.get('languages', [])
input_devices2 = secondary_settings.get('input_devices', [])
stream_passwords2 = secondary_settings.get('stream_passwords', []) if 'stream_passwords' in secondary_settings else []
for i in range(r2_num_streams):
with st.expander(f"Stream {i+1} - Radio 2", expanded=True):
saved_streams = saved_r2_config.get('streams', [])
saved_stream = saved_streams[i] if i < len(saved_streams) else {}
# Get saved values from flat secondary structure
saved_name2 = channel_names2[i] if i < len(channel_names2) else f'Dante_R2_S{i+1}'
saved_program_info2 = program_infos2[i] if i < len(program_infos2) else f'Dante Radio 2 Stream {i+1}'
saved_language2 = languages2[i] if i < len(languages2) else 'eng'
saved_password2 = stream_passwords2[i] if i < len(stream_passwords2) else ''
saved_input_device2 = input_devices2[i] if i < len(input_devices2) else None
# First row: Channel name and password
col_name, col_pwd = st.columns([2, 1])
with col_name:
stream_name = st.text_input(
f"Channel Name",
value=saved_stream.get('name', f'Dante_R2_S{i+1}'),
value=saved_name2,
disabled=is_streaming,
key=f"r2_stream_{i}_name"
)
with col_pwd:
stream_password = st.text_input(
f"Stream Password",
value=saved_stream.get('stream_password', ''),
value=saved_password2,
type="password",
disabled=is_streaming,
key=f"r2_stream_{i}_password",
help="Optional: Set a broadcast code for this stream"
)
# Second row: Program info and language
col_prog, col_lang = st.columns([2, 1])
with col_prog:
program_info = st.text_input(
f"Program Info",
value=saved_stream.get('program_info', f'Dante Radio 2 Stream {i+1}'),
value=saved_program_info2,
disabled=is_streaming,
key=f"r2_stream_{i}_program"
)
with col_lang:
language = st.text_input(
f"Language",
value=saved_stream.get('language', 'eng'),
value=saved_language2,
disabled=is_streaming,
key=f"r2_stream_{i}_lang",
help="ISO 639-3 language code"
@@ -1245,10 +1412,10 @@ else:
with col_device:
# Session state key for persisting the selection
device_session_key = f"r2_stream_{i}_device_saved"
if not is_streaming and input_options:
# Get default from session state first, then from saved settings
default_input_name = st.session_state.get(device_session_key, saved_stream.get('input_device'))
default_input_name = st.session_state.get(device_session_key, saved_input_device2)
default_input_label = None
for label, name in option_name_map.items():
if name == default_input_name:
@@ -1256,7 +1423,7 @@ else:
break
if default_input_label not in input_options and input_options:
default_input_label = input_options[0]
selected_option = st.selectbox(
f"Input Device",
input_options,
@@ -1269,7 +1436,7 @@ else:
st.session_state[device_session_key] = input_device
else:
# When streaming, get the device from session state
current_device = st.session_state.get(device_session_key, saved_stream.get('input_device', 'No device'))
current_device = st.session_state.get(device_session_key, saved_input_device2 or 'No device')
# Convert internal name to display label
display_label = current_device
@@ -1354,8 +1521,30 @@ else:
if audio_mode in ("USB", "Network"):
# USB/Network: single set of controls shared with the single channel
# Use saved settings if audio_mode matches, otherwise use defaults
saved_audio_mode = saved_settings.get('audio_mode')
if saved_audio_mode in ("USB", "Network"):
# Map saved sampling rate to quality label
saved_rate = saved_settings.get('auracast_sampling_rate_hz')
if saved_rate == 48000:
default_quality = "High (48kHz)"
elif saved_rate == 32000:
default_quality = "Good (32kHz)"
elif saved_rate == 24000:
default_quality = "Medium (24kHz)"
elif saved_rate == 16000:
default_quality = "Fair (16kHz)"
else:
default_quality = "Medium (24kHz)"
saved_pwd = saved_settings.get('stream_password', '')
else:
# Use defaults when switching from another mode
default_quality = "Medium (24kHz)" if "Medium (24kHz)" in quality_options else quality_options[0]
saved_pwd = ''
quality_options = list(QUALITY_MAP.keys())
default_quality = "Medium (24kHz)" if "Medium (24kHz)" in quality_options else quality_options[0]
if default_quality not in quality_options:
default_quality = quality_options[0]
quality = st.selectbox(
"Stream Quality (Sampling Rate)",
quality_options,
@@ -1366,7 +1555,7 @@ else:
stream_passwort = st.text_input(
"Stream Passwort",
value="",
value=saved_pwd,
type="password",
disabled=is_streaming,
help="Optional: Set a broadcast code to protect your stream. Leave empty for an open (uncoded) broadcast."
@@ -1618,7 +1807,7 @@ if start_stream:
analog_gain_db_right=cfg.get('analog_gain_db_right', 0.0),
bigs=[
auracast_config.AuracastBigConfig(
code=(cfg['stream_passwort'].strip() or None),
code=((cfg['stream_passwort'] or '').strip() or None),
name=cfg['name'],
program_info=cfg['program_info'],
language=cfg['language'],
+247 -42
View File
@@ -10,6 +10,7 @@ from datetime import datetime
import asyncio
import random
import subprocess
import threading
from dotenv import load_dotenv
from fastapi import FastAPI, HTTPException
@@ -208,6 +209,28 @@ multicaster1: multicast_control.Multicaster | None = None
multicaster2: multicast_control.Multicaster | None = None
_stream_lock = asyncio.Lock() # serialize initialize/stop_audio on API side
# BLE / audio event loop set in __main__ before uvicorn starts.
# All coroutines that touch Bumble objects or the audio pipeline MUST run
# on this loop. HTTP handlers call _on_ble_loop() to cross into it.
_ble_loop: asyncio.AbstractEventLoop | None = None
async def _on_ble_loop(coro):
"""Submit *coro* to the BLE event loop and await the result.
Called from uvicorn's event loop. Bridges HTTP handler coroutines into
the isolated BLE loop so that serial I/O (serial_asyncio / HCI) and the
audio pipeline are never preempted by HTTP accept/read/write callbacks.
asyncio.run_coroutine_threadsafe() schedules the coroutine on _ble_loop
(thread-safe), returning a concurrent.futures.Future.
asyncio.wrap_future() adapts that into an asyncio.Future so the caller
can simply `await` it inside uvicorn's loop.
"""
assert _ble_loop is not None, "BLE loop not yet initialised"
future = asyncio.run_coroutine_threadsafe(coro, _ble_loop)
return await asyncio.wrap_future(future)
async def _init_i2c_on_startup() -> None:
# Ensure i2c-dev kernel module is loaded (required for /dev/i2c-* access)
@@ -422,6 +445,11 @@ async def init_radio(transport: str, conf: auracast_config.AuracastConfigGroup,
first_source = conf.bigs[0].audio_source if conf.bigs else ''
input_device_name = None
audio_mode_persist = 'Demo'
# Capture original per-BIG device names before transformation
original_input_devices = [
big.audio_source.split(':', 1)[1] if (isinstance(big.audio_source, str) and big.audio_source.startswith('device:')) else None
for big in conf.bigs
]
if any(isinstance(b.audio_source, str) and b.audio_source.startswith('device:') for b in conf.bigs):
if isinstance(first_source, str) and first_source.startswith('device:'):
input_device_name = first_source.split(':', 1)[1] if ':' in first_source else None
@@ -574,6 +602,7 @@ async def init_radio(transport: str, conf: auracast_config.AuracastConfigGroup,
'languages': [big.language for big in conf.bigs],
'audio_mode': audio_mode_persist,
'input_device': input_device_name,
'input_devices': original_input_devices,
'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,
@@ -602,7 +631,10 @@ async def init_radio(transport: str, conf: auracast_config.AuracastConfigGroup,
@app.post("/init")
async def initialize(conf: auracast_config.AuracastConfigGroup):
"""Initializes the primary broadcaster on the streamer thread."""
"""Initializes the primary broadcaster on the BLE loop."""
return await _on_ble_loop(_initialize_impl(conf))
async def _initialize_impl(conf: auracast_config.AuracastConfigGroup):
async with _stream_lock:
global multicaster1, global_config_group
mc, persisted = await init_radio(TRANSPORT1, conf, multicaster1)
@@ -612,7 +644,10 @@ async def initialize(conf: auracast_config.AuracastConfigGroup):
@app.post("/init2")
async def initialize2(conf: auracast_config.AuracastConfigGroup):
"""Initializes the secondary broadcaster on the streamer thread."""
"""Initializes the secondary broadcaster on the BLE loop."""
return await _on_ble_loop(_initialize2_impl(conf))
async def _initialize2_impl(conf: auracast_config.AuracastConfigGroup):
async with _stream_lock:
global multicaster2
mc, persisted = await init_radio(TRANSPORT2, conf, multicaster2)
@@ -631,7 +666,11 @@ async def set_led_enabled(body: dict):
@app.post("/stop_audio")
async def stop_audio():
"""Stops streaming on both multicaster1 and multicaster2 (worker thread)."""
"""Stops streaming on both multicasters via the BLE loop."""
return await _on_ble_loop(_stop_audio_impl())
async def _stop_audio_impl():
"""Runs on BLE loop: stops all streamers and persists is_streaming=False."""
try:
was_running = await _stop_all()
@@ -681,9 +720,9 @@ async def set_adc_gain(payload: dict):
@app.post("/stream_lc3")
async def send_audio(audio_data: dict[str, str]):
"""Sends a block of pre-coded LC3 audio via the worker."""
"""Sends a block of pre-coded LC3 audio via the BLE loop."""
try:
await _stream_lc3(audio_data, list(global_config_group.bigs))
await _on_ble_loop(_stream_lc3(audio_data, list(global_config_group.bigs)))
return {"status": "audio_sent"}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@@ -1058,6 +1097,19 @@ async def _autostart_from_settings():
await do_primary()
await do_secondary()
async def _ble_startup():
"""I2C init, ADC level reset, and autostart task scheduling on the BLE loop.
Bridged from _startup_autostart_event() so that these async subprocess
calls and the long-lived autostart coroutine all run on _ble_loop, never
on uvicorn's HTTP loop.
"""
await _init_i2c_on_startup()
await _set_adc_level(0.0, 0.0)
log.info("[STARTUP] Scheduling autostart task on BLE loop")
asyncio.create_task(_autostart_from_settings())
@app.on_event("startup")
async def _startup_autostart_event():
# Spawn the autostart task without blocking startup
@@ -1078,12 +1130,11 @@ async def _startup_autostart_event():
# Hydrate settings cache once to avoid disk I/O during /status
_load_led_settings()
_init_settings_cache_from_disk()
await _init_i2c_on_startup()
# Ensure ADC mixer level is set at startup (default 0 dB)
await _set_adc_level(0.0, 0.0)
refresh_pw_cache()
log.info("[STARTUP] Scheduling autostart task")
asyncio.create_task(_autostart_from_settings())
# I2C init, ADC setup and the autostart task must run on the BLE loop so
# they share the same event loop as the Bumble HCI transport.
log.info("[STARTUP] Bridging I2C init and autostart to BLE loop")
asyncio.run_coroutine_threadsafe(_ble_startup(), _ble_loop)
@app.get("/audio_inputs_pw_usb")
async def audio_inputs_pw_usb():
@@ -1154,6 +1205,9 @@ async def refresh_audio_devices():
@app.post("/shutdown")
async def shutdown():
"""Stops broadcasting and releases all audio/Bluetooth resources."""
return await _on_ble_loop(_shutdown_impl())
async def _shutdown_impl():
try:
await _stop_all()
return {"status": "stopped"}
@@ -1166,6 +1220,9 @@ async def system_reboot():
Requires the service user to have passwordless sudo permissions to run 'reboot'.
"""
return await _on_ble_loop(_system_reboot_impl())
async def _system_reboot_impl():
try:
# Best-effort: stop any active streaming cleanly WITHOUT persisting state
try:
@@ -1189,47 +1246,27 @@ async def system_reboot():
@app.post("/restart_dep")
async def restart_dep():
"""Restart DEP by running dep.sh stop then dep.sh start in the dep directory.
"""Restart DEP via systemctl restart dep.service.
Requires the service user to have passwordless sudo permissions to run dep.sh.
Requires the service user to have passwordless sudo permissions for systemctl.
"""
try:
# Get the dep directory path (dep.sh is in dante_package subdirectory)
dep_dir = os.path.join(os.path.dirname(__file__), '..', '..', 'dep', 'dante_package')
# Run dep.sh stop first
log.info("Stopping DEP...")
stop_process = await asyncio.create_subprocess_exec(
"sudo", "bash", "dep.sh", "stop",
cwd=dep_dir,
log.info("Restarting DEP via systemctl...")
proc = await asyncio.create_subprocess_exec(
"sudo", "systemctl", "restart", "dep.service",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
stop_stdout, stop_stderr = await stop_process.communicate()
if stop_process.returncode != 0:
error_msg = stop_stderr.decode() if stop_stderr else "Unknown error"
log.error(f"Failed to stop DEP: {error_msg}")
raise HTTPException(status_code=500, detail=f"Failed to stop DEP: {error_msg}")
# Run dep.sh start after stop succeeds
log.info("Starting DEP...")
start_process = await asyncio.create_subprocess_exec(
"sudo", "bash", "dep.sh", "start",
cwd=dep_dir,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
start_stdout, start_stderr = await start_process.communicate()
if start_process.returncode == 0:
stdout, stderr = await proc.communicate()
if proc.returncode == 0:
log.info("DEP restarted successfully")
return {"status": "success", "message": "DEP restarted successfully"}
else:
error_msg = start_stderr.decode() if start_stderr else "Unknown error"
log.error(f"Failed to start DEP: {error_msg}")
raise HTTPException(status_code=500, detail=f"Failed to start DEP: {error_msg}")
error_msg = stderr.decode() if stderr else "Unknown error"
log.error(f"Failed to restart DEP: {error_msg}")
raise HTTPException(status_code=500, detail=f"Failed to restart DEP: {error_msg}")
except HTTPException:
raise
except Exception as e:
@@ -1322,6 +1359,9 @@ async def check_update():
@app.post("/system_update")
async def system_update():
"""Update application: git pull main branch (latest tag), poetry install, restart services."""
return await _on_ble_loop(_system_update_impl())
async def _system_update_impl():
try:
# Best-effort: stop any active streaming cleanly
try:
@@ -1789,5 +1829,170 @@ if __name__ == '__main__':
level=os.environ.get('LOG_LEVEL', log.INFO),
format='%(module)s.py:%(lineno)d %(levelname)s: %(message)s'
)
# ── GIL switch interval ─────────────────────────────────────────────────
# CPython releases the GIL every sys.getswitchinterval() seconds (default
# 5 ms). The audio pipeline fires every 10 ms, so a 5 ms granularity
# means up to half a frame period can be wasted waiting for the GIL.
# Reducing to 1 ms gives the BLE thread much tighter access.
import sys
sys.setswitchinterval(0.001)
log.info("GIL switch interval set to 1 ms")
# ── BLE / audio event loop ──────────────────────────────────────────────
# Bumble (serial_asyncio / HCI) and the audio pipeline run exclusively on
# this loop. Uvicorn's HTTP accept/read/write callbacks run on a separate
# asyncio loop in the main thread, so they can never stall BLE advertising
# or audio encoding.
#
# Route handlers that touch Bumble objects call _on_ble_loop(), which uses
# asyncio.run_coroutine_threadsafe() + asyncio.wrap_future() to submit the
# coroutine to _ble_loop and await the result back in uvicorn's loop.
# Hot-path read-only endpoints (/status, /audio_level*) access
# multicaster state directly Python's GIL makes attribute reads safe.
def _pthread_sched_lib():
"""Return a ctypes handle with correctly typed pthread scheduling symbols.
Uses RTLD_DEFAULT (ctypes.CDLL(None)) to resolve symbols from all
currently loaded shared libraries. This handles both:
- glibc < 2.34: pthread_self/pthread_setschedparam live in libpthread.so.0
- glibc >= 2.34: pthreads merged into libc.so.6
using find_library("c") would miss libpthread on older glibc and cause
a NULL function pointer → SEGV when called.
Explicit restype/argtypes are mandatory: pthread_t is c_ulong (64-bit
on ARM64/x86-64) but ctypes defaults to c_int (32-bit), truncating
the thread handle and causing a SEGV inside pthread_setschedparam.
"""
import ctypes
SCHED_FIFO = 1
SCHED_OTHER = 0
class SchedParam(ctypes.Structure):
_fields_ = [("sched_priority", ctypes.c_int)]
lib = ctypes.CDLL(None, use_errno=True) # RTLD_DEFAULT
lib.pthread_self.restype = ctypes.c_ulong
lib.pthread_self.argtypes = []
lib.pthread_getschedparam.restype = ctypes.c_int
lib.pthread_getschedparam.argtypes = [
ctypes.c_ulong,
ctypes.POINTER(ctypes.c_int),
ctypes.POINTER(SchedParam),
]
lib.pthread_setschedparam.restype = ctypes.c_int
lib.pthread_setschedparam.argtypes = [
ctypes.c_ulong,
ctypes.c_int,
ctypes.POINTER(SchedParam),
]
return lib, SchedParam, SCHED_FIFO, SCHED_OTHER
def _configure_ble_thread_scheduling():
"""Confirm or establish SCHED_FIFO for the BLE/audio thread.
When launched via the systemd unit (CPUSchedulingPolicy=fifo), new
threads inherit the process RT policy automatically just log and
return. When run directly (development), attempt to elevate to
SCHED_FIFO/30 (requires CAP_SYS_NICE), falling back gracefully.
"""
import ctypes
try:
lib, SchedParam, SCHED_FIFO, _ = _pthread_sched_lib()
tid = lib.pthread_self()
policy = ctypes.c_int(-1)
param = SchedParam(0)
lib.pthread_getschedparam(tid, ctypes.byref(policy), ctypes.byref(param))
if policy.value == SCHED_FIFO:
log.info("[BLE-LOOP] Already SCHED_FIFO priority=%d (inherited from systemd)",
param.sched_priority)
return
param.sched_priority = 30
ret = lib.pthread_setschedparam(tid, SCHED_FIFO, ctypes.byref(param))
if ret == 0:
log.info("[BLE-LOOP] SCHED_FIFO priority=30 set")
else:
err = ctypes.get_errno()
log.warning("[BLE-LOOP] SCHED_FIFO failed (errno=%d: %s) "
"use systemd CPUSchedulingPolicy=fifo or grant CAP_SYS_NICE",
err, os.strerror(err))
try:
os.setpriority(os.PRIO_PROCESS, 0,
os.getpriority(os.PRIO_PROCESS, 0) - 5)
except PermissionError:
pass
except Exception as exc:
log.warning("[BLE-LOOP] Scheduling setup error: %s", exc)
def _configure_http_thread_scheduling():
"""Demote the HTTP (uvicorn) thread to SCHED_OTHER + nice=+10.
When systemd sets CPUSchedulingPolicy=fifo, every thread in the
process including uvicorn's main loop inherits SCHED_FIFO.
We demote the HTTP thread back to SCHED_OTHER so the BLE thread
always wins CPU arbitration when both are runnable.
Lowering scheduling policy never requires special privileges.
"""
import ctypes
try:
lib, SchedParam, SCHED_FIFO, SCHED_OTHER = _pthread_sched_lib()
tid = lib.pthread_self()
policy = ctypes.c_int(-1)
param = SchedParam(0)
lib.pthread_getschedparam(tid, ctypes.byref(policy), ctypes.byref(param))
if policy.value == SCHED_FIFO:
param.sched_priority = 0
ret = lib.pthread_setschedparam(tid, SCHED_OTHER, ctypes.byref(param))
if ret == 0:
log.info("[HTTP] Demoted SCHED_FIFO → SCHED_OTHER")
else:
err = ctypes.get_errno()
log.warning("[HTTP] Could not demote from SCHED_FIFO (errno=%d)", err)
else:
log.info("[HTTP] Already SCHED_OTHER, no demotion needed")
except Exception as exc:
log.warning("[HTTP] Scheduling demotion error: %s", exc)
try:
os.nice(10)
log.info("[HTTP] nice=+10 (lower priority)")
except Exception as exc:
log.debug("[HTTP] os.nice: %s", exc)
_ble_loop_ready = threading.Event()
def _run_ble_loop():
# Confirm or establish RT scheduling before entering the event loop.
_configure_ble_thread_scheduling()
async def _ble_runner():
global _ble_loop
_ble_loop = asyncio.get_running_loop()
_ble_loop_ready.set()
# Keep the loop alive; it is stopped when the process exits because
# this is a daemon thread.
await asyncio.Event().wait()
asyncio.run(_ble_runner())
_ble_thread = threading.Thread(target=_run_ble_loop, name="ble-loop", daemon=True)
_ble_thread.start()
if not _ble_loop_ready.wait(timeout=5):
log.error("BLE event loop failed to start within 5 s aborting")
raise RuntimeError("BLE event loop startup timeout")
log.info("BLE event loop started on thread '%s'", _ble_thread.name)
# ── HTTP / uvicorn event loop (main thread) ─────────────────────────────
# Demote the HTTP thread from SCHED_FIFO (if set by systemd) to
# SCHED_OTHER + nice=+10 so the BLE thread always preempts it.
_configure_http_thread_scheduling()
# Bind to localhost only for security: prevents network access, only frontend on same machine can connect
uvicorn.run(app, host="127.0.0.1", port=5000, access_log=False)
@@ -0,0 +1 @@
Placeholder file to have the activation folder available, otherwise dante activation script fails with 'Unable to write'.
@@ -1,5 +1,5 @@
{
"trialMode": true,
"trialMode": false,
"$schema": "./dante.json_schema.json",
"platform":
{
@@ -16,7 +16,7 @@
48000
],
"samplesPerPeriod" : 16,
"periodsPerBuffer" : 300,
"periodsPerBuffer" : 150,
"networkLatencyMinMs" : 2,
"networkLatencyDefaultMs" : 5,
"supportedEncodings" :
@@ -24,7 +24,10 @@
"PCM16"
],
"defaultEncoding" : "PCM16",
"numDepCores" : 1
"numDepCores" :
[
3
]
},
"network" :
{
@@ -50,31 +53,32 @@
"alsaAsrc":
{
"enableAlsaAsrc": true,
"cpuAffinity": 3,
"deviceConfigurations": [
{
"deviceIdentifier": "hw:0,0",
"deviceIdentifier": "hw:6,0,0",
"direction": "playback",
"bitDepth": 16,
"numOpenChannels": 6,
"alsaChannelRange": "0-5",
"danteChannelRange": "0-5",
"bufferSize": 4800,
"bufferSize": 960,
"samplesPerPeriod": 16
}
]
},
"product" :
{
"manfId" : "Audinate",
"manfName" : "Audinate Pty Ltd",
"modelId" : "OEMDEP",
"modelName" : "Linux Dante Embedded Platform",
"manfId" : "SummitFC",
"manfName" : "Summitwave FlexCo",
"modelId" : "TX",
"modelName" : "Summitwave TX",
"modelVersion" :
{
"major" : 9,
"minor" : 9,
"bugfix" : 99
"major" : 1,
"minor" : 0,
"bugfix" : 0
},
"devicePrefix" : "DEP"
"devicePrefix" : "SW-TX"
}
}
+16
View File
@@ -37,4 +37,20 @@ sudo openocd \
-c "reset run" \
-c "shutdown"
sudo openocd \
-f ./raspberrypi-${INTERFACE}.cfg \
-c "init" \
-c "nrf54l.dap apreg 2 0x000 0x1" \
-c "sleep 100" \
-c "nrf54l.dap apreg 2 0x000 0x0" \
-c "shutdown"
sudo openocd \
-f ./raspberrypi-${INTERFACE}.cfg \
-c "init" \
-c "nrf54l.dap apreg 2 0x000 0x4" \
-c "sleep 100" \
-c "nrf54l.dap apreg 2 0x000 0x0" \
-c "shutdown"
echo "Flashing complete."
+83
View File
@@ -0,0 +1,83 @@
#!/bin/bash
# NetworkManager dispatcher script: 10-link-local-mgmt
#
# Temporarily suppresses IPv4 link-local when a DHCP address is available,
# using nmcli device modify (active session only, NOT saved to the profile).
# The persistent profile always keeps ipv4.link-local=enabled so that
# direct-connect (no DHCP) plug-ins always activate and trigger events.
#
# Triggers: up, down, dhcp4-change on ethernet interfaces
# Install to: /etc/NetworkManager/dispatcher.d/10-link-local-mgmt
# Permissions: root:root 0755
INTERFACE="$1"
ACTION="$2"
CONNECTION_NAME="${CONNECTION_ID:-}"
# Only handle ethernet interfaces
if [[ ! "$INTERFACE" =~ ^eth ]]; then
exit 0
fi
# If CONNECTION_ID env var is not set, look up the active connection for this interface
if [ -z "$CONNECTION_NAME" ]; then
CONNECTION_NAME=$(nmcli -t -f NAME,DEVICE connection show --active 2>/dev/null \
| grep ":${INTERFACE}$" | cut -d: -f1 | head -n1)
[ -z "$CONNECTION_NAME" ] && exit 0
fi
# Update /etc/avahi/hosts to point mDNS hostname at the best available DHCP address
# across all ethernet interfaces (so Avahi doesn't advertise a link-local address).
update_avahi() {
local hostname
hostname=$(hostname)
# Find first non-link-local IPv4 across all ethernet interfaces
local dhcp_ip
dhcp_ip=$(ip -4 addr show 2>/dev/null \
| grep -A5 ': eth' \
| grep -oP '(?<=inet\s)\d+(\.\d+){3}' \
| grep -v '^127\.' \
| grep -v '^169\.254\.' \
| head -n1)
if [ -n "$dhcp_ip" ]; then
mkdir -p /etc/avahi
echo "$dhcp_ip $hostname $hostname.local" > /etc/avahi/hosts
logger -t nm-link-local "Avahi: pinned $hostname -> $dhcp_ip"
else
rm -f /etc/avahi/hosts
logger -t nm-link-local "Avahi: removed hosts pin, using all addresses"
fi
systemctl restart avahi-daemon 2>/dev/null
}
case "$ACTION" in
up|dhcp4-change)
DHCP_IP=$(ip -4 addr show "$INTERFACE" 2>/dev/null \
| grep -oP '(?<=inet\s)\d+(\.\d+){3}' \
| grep -v '^127\.' \
| grep -v '^169\.254\.' \
| head -n1)
if [ -n "$DHCP_IP" ]; then
logger -t nm-link-local "[$INTERFACE] DHCP $DHCP_IP detected — suppressing link-local (session only)"
# Use device modify (not connection modify) so the persistent profile keeps
# ipv4.link-local=enabled. This ensures direct-connect plug-ins always activate.
# Run in background after a delay — nmcli blocks on NM, which is waiting for
# this dispatcher to return, causing a deadlock if called synchronously.
(sleep 2 && nmcli device modify "$INTERFACE" ipv4.link-local disabled 2>/dev/null \
&& logger -t nm-link-local "[$INTERFACE] Link-local suppressed for current session") &
else
logger -t nm-link-local "[$INTERFACE] No DHCP on $INTERFACE — keeping link-local active"
fi
update_avahi
;;
down)
# Profile always has ipv4.link-local=enabled so no action needed here.
# The suppression from device modify was session-only and is gone when the
# connection goes down.
logger -t nm-link-local "[$INTERFACE] Down — link-local will be active on next connect"
update_avahi
;;
esac
+2
View File
@@ -10,6 +10,8 @@ WorkingDirectory=/home/caster/bumble-auracast/src/auracast/server
ExecStart=/home/caster/bumble-auracast/src/auracast/server/start_frontend_https.sh
Restart=on-failure
Environment=LOG_LEVEL=INFO
AllowedCPUs=0
CPUAffinity=0
[Install]
WantedBy=multi-user.target
+2
View File
@@ -9,6 +9,8 @@ ExecStart=/home/caster/bumble-auracast/.venv/bin/python src/auracast/multicast_s
Restart=on-failure
Environment=PYTHONUNBUFFERED=1
Environment=LOG_LEVEL=INFO
AllowedCPUs=0
CPUAffinity=0
[Install]
WantedBy=default.target
+5 -2
View File
@@ -1,6 +1,7 @@
[Unit]
Description=Auracast Backend Server
After=network.target
After=network.target dep.service
Wants=dep.service
[Service]
Type=simple
@@ -10,8 +11,10 @@ Restart=on-failure
Environment=PYTHONUNBUFFERED=1
Environment=LOG_LEVEL=INFO
CPUSchedulingPolicy=fifo
CPUSchedulingPriority=99
CPUSchedulingPriority=10
LimitRTPRIO=99
AllowedCPUs=0,1,2
CPUAffinity=0,1,2
[Install]
WantedBy=default.target
+13
View File
@@ -0,0 +1,13 @@
[Unit]
Description=DEP (Dante Embedded Platform) Container
After=network.target
[Service]
Type=oneshot
RemainAfterExit=yes
WorkingDirectory=/home/caster/bumble-auracast/src/dep/dante_package
ExecStart=/bin/bash dep.sh start
ExecStop=/bin/bash dep.sh stop
[Install]
WantedBy=multi-user.target
+2
View File
@@ -9,6 +9,8 @@ ExecStartPre=/bin/sh -lc 'for i in $(seq 1 60); do ip route show default >/dev/n
ExecStart=/usr/bin/pipewire-aes67 -c /home/caster/bumble-auracast/src/service/aes67/pipewire-aes67.conf
Restart=always
RestartSec=5s
AllowedCPUs=0
CPUAffinity=0
# Avoid StartLimitHit on quick failures during boot; let RestartSec handle pacing
StartLimitIntervalSec=0
+2
View File
@@ -6,6 +6,8 @@ After=network.target
Type=simple
ExecStart=/usr/sbin/ptp4l -i eth0 -f /home/caster/bumble-auracast/src/service/aes67/ptp_aes67_1.conf
Restart=on-failure
AllowedCPUs=0
CPUAffinity=0
StandardOutput=journal
StandardError=journal
@@ -14,24 +14,22 @@ while IFS=: read -r name type; do
fi
done < <(nmcli -t -f NAME,TYPE connection show)
# Configure Avahi to prefer DHCP address over static fallback for mDNS
# Get the DHCP-assigned IP (first non-localhost, non-192.168.42.10 IP)
DHCP_IP=$(ip -4 addr show eth0 2>/dev/null | grep -oP '(?<=inet\s)\d+(\.\d+){3}' | grep -v '^127\.' | grep -v '^169\.254\.' | head -n1)
HOSTNAME=$(hostname)
if [ -n "$DHCP_IP" ]; then
echo "DHCP address detected: $DHCP_IP, configuring Avahi to prefer it for mDNS."
# Add entry to /etc/avahi/hosts to explicitly map hostname to DHCP IP
sudo mkdir -p /etc/avahi
echo "$DHCP_IP $HOSTNAME $HOSTNAME.local" | sudo tee /etc/avahi/hosts > /dev/null
# Restart avahi to apply the hosts file
sudo systemctl restart avahi-daemon
else
echo "No DHCP address detected, mDNS will use link local"
# Remove hosts file to let Avahi advertise all IPs
sudo rm -f /etc/avahi/hosts
sudo systemctl restart avahi-daemon
fi
# Ensure Loopback is loaded with a fixed name and index
# Needed for dante
# TODO image when we create the next image this should be part of it
echo "options snd-aloop index=6 id=Loopback pcm_substreams=6" | sudo tee /etc/modprobe.d/snd-aloop.conf
echo snd-aloop | sudo tee /etc/modules-load.d/snd-aloop.conf
# Install NetworkManager dispatcher script for link-local / Avahi management
sudo cp /home/caster/bumble-auracast/src/service/10-link-local-mgmt /etc/NetworkManager/dispatcher.d/10-link-local-mgmt
sudo chown root:root /etc/NetworkManager/dispatcher.d/10-link-local-mgmt
sudo chmod 755 /etc/NetworkManager/dispatcher.d/10-link-local-mgmt
# Copy system service file for DEP
sudo cp /home/caster/bumble-auracast/src/service/dep.service /etc/systemd/system/dep.service
# Copy system service file for frontend
sudo cp /home/caster/bumble-auracast/src/service/auracast-frontend.service /etc/systemd/system/auracast-frontend.service
@@ -40,20 +38,25 @@ sudo cp /home/caster/bumble-auracast/src/service/auracast-frontend.service /etc/
mkdir -p /home/caster/.config/systemd/user
cp /home/caster/bumble-auracast/src/service/auracast-server.service /home/caster/.config/systemd/user/auracast-server.service
# Reload systemd for frontend
# Reload systemd for frontend and dep
sudo systemctl daemon-reload
# Reload user systemd for server
systemctl --user daemon-reload
# Enable DEP to start on boot (system)
sudo systemctl enable dep.service
# Enable frontend to start on boot (system)
sudo systemctl enable auracast-frontend.service
# Enable server to start on boot (user)
systemctl --user enable auracast-server.service
# Restart both
# Restart all
sudo systemctl restart dep.service
sudo systemctl restart auracast-frontend.service
systemctl --user restart auracast-server.service
#print status
sudo systemctl status dep.service --no-pager
sudo systemctl status auracast-frontend.service --no-pager
systemctl --user status auracast-server.service --no-pager