Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 15ec3aaef6 | |||
| de6294837b | |||
| 6375b215cb | |||
| 25df79eef5 | |||
| 9c251b7a66 | |||
| c24be9f366 | |||
| c659d632b0 | |||
| 14827288e7 | |||
| 2410b01f15 | |||
| 67c774204a | |||
| c56012134c |
@@ -53,3 +53,9 @@ src/scripts/temperature_log*
|
|||||||
|
|
||||||
src/auracast/server/recordings/
|
src/auracast/server/recordings/
|
||||||
src/auracast/server/led_settings.json
|
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
|
||||||
|
|||||||
@@ -206,7 +206,7 @@ class PyAlsaAudioInput(audio_io.ThreadedAudioInput):
|
|||||||
length, data = self._pcm.read_sw(frame_size + self._bang_bang)
|
length, data = self._pcm.read_sw(frame_size + self._bang_bang)
|
||||||
avail = self._pcm.avail()
|
avail = self._pcm.avail()
|
||||||
SETPOINT = 120
|
SETPOINT = 120
|
||||||
TOLERANCE = 40
|
TOLERANCE = 80
|
||||||
if avail < SETPOINT - TOLERANCE:
|
if avail < SETPOINT - TOLERANCE:
|
||||||
self._bang_bang = -1
|
self._bang_bang = -1
|
||||||
elif avail > SETPOINT + TOLERANCE:
|
elif avail > SETPOINT + TOLERANCE:
|
||||||
@@ -811,7 +811,11 @@ class Streamer():
|
|||||||
if input_format == 'auto':
|
if input_format == 'auto':
|
||||||
raise ValueError('input format details required for alsa input')
|
raise ValueError('input format details required for alsa input')
|
||||||
pcm = audio_io.PcmFormat.from_str(input_format)
|
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:
|
else:
|
||||||
audio_input = await audio_io.create_audio_input(audio_source, input_format)
|
audio_input = await audio_io.create_audio_input(audio_source, input_format)
|
||||||
# Store early so stop_streaming can close even if open() fails
|
# Store early so stop_streaming can close even if open() fails
|
||||||
|
|||||||
@@ -71,6 +71,10 @@ if not is_pw_disabled():
|
|||||||
with st.form("signin_form"):
|
with st.form("signin_form"):
|
||||||
pw = st.text_input("Password", type="password")
|
pw = st.text_input("Password", type="password")
|
||||||
submitted = st.form_submit_button("Sign in")
|
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 submitted:
|
||||||
if verify_password(pw, pw_rec):
|
if verify_password(pw, pw_rec):
|
||||||
st.session_state['frontend_authenticated'] = True
|
st.session_state['frontend_authenticated'] = True
|
||||||
@@ -433,13 +437,43 @@ else:
|
|||||||
disabled=is_streaming
|
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())
|
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(
|
quality1 = st.selectbox(
|
||||||
"Stream Quality (Radio 1)",
|
"Stream Quality (Radio 1)",
|
||||||
quality_options,
|
quality_options,
|
||||||
@@ -450,7 +484,7 @@ else:
|
|||||||
|
|
||||||
stream_passwort1 = st.text_input(
|
stream_passwort1 = st.text_input(
|
||||||
"Stream Passwort (Radio 1)",
|
"Stream Passwort (Radio 1)",
|
||||||
value="",
|
value=saved_pwd,
|
||||||
type="password",
|
type="password",
|
||||||
disabled=is_streaming,
|
disabled=is_streaming,
|
||||||
help="Optional: Set a broadcast code for Radio 1."
|
help="Optional: Set a broadcast code for Radio 1."
|
||||||
@@ -557,7 +591,10 @@ else:
|
|||||||
input_device1 = None
|
input_device1 = None
|
||||||
else:
|
else:
|
||||||
# Mono mode: show all available channels
|
# Mono mode: show all available channels
|
||||||
|
saved_input_device = saved_settings.get('input_device')
|
||||||
default_r1_idx = 0
|
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_device1 = st.selectbox(
|
||||||
"Input Device (Radio 1)",
|
"Input Device (Radio 1)",
|
||||||
analog_names,
|
analog_names,
|
||||||
@@ -606,22 +643,53 @@ else:
|
|||||||
)
|
)
|
||||||
|
|
||||||
if radio2_enabled and not stereo_enabled:
|
if radio2_enabled and not stereo_enabled:
|
||||||
# Use analog-specific defaults for Radio 2
|
# Use saved settings if audio_mode matches, otherwise use analog-specific defaults for Radio 2
|
||||||
default_name_r2 = "Analog_Radio_2"
|
secondary_settings = saved_settings.get('secondary', {})
|
||||||
default_program_info_r2 = "Analog Radio Broadcast"
|
saved_audio_mode = saved_settings.get('audio_mode')
|
||||||
default_lang_r2 = "deu"
|
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(
|
quality2 = st.selectbox(
|
||||||
"Stream Quality (Radio 2)",
|
"Stream Quality (Radio 2)",
|
||||||
quality_options,
|
quality_options,
|
||||||
index=quality_options.index(default_quality),
|
index=quality_options.index(default_quality_r2),
|
||||||
disabled=is_streaming,
|
disabled=is_streaming,
|
||||||
help="Select the audio sampling rate for Radio 2."
|
help="Select the audio sampling rate for Radio 2."
|
||||||
)
|
)
|
||||||
|
|
||||||
stream_passwort2 = st.text_input(
|
stream_passwort2 = st.text_input(
|
||||||
"Stream Passwort (Radio 2)",
|
"Stream Passwort (Radio 2)",
|
||||||
value="",
|
value=saved_pwd_r2,
|
||||||
type="password",
|
type="password",
|
||||||
disabled=is_streaming,
|
disabled=is_streaming,
|
||||||
help="Optional: Set a broadcast code for Radio 2."
|
help="Optional: Set a broadcast code for Radio 2."
|
||||||
@@ -682,7 +750,11 @@ else:
|
|||||||
|
|
||||||
if not is_streaming:
|
if not is_streaming:
|
||||||
if analog_names:
|
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
|
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_device2 = st.selectbox(
|
||||||
"Input Device (Radio 2)",
|
"Input Device (Radio 2)",
|
||||||
analog_names,
|
analog_names,
|
||||||
@@ -774,10 +846,15 @@ else:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Dante stereo mode toggle
|
# 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(
|
dante_stereo_enabled = st.checkbox(
|
||||||
"🎧 Stereo Mode",
|
"🎧 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.",
|
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
|
disabled=is_streaming
|
||||||
)
|
)
|
||||||
@@ -790,9 +867,19 @@ else:
|
|||||||
"dante_asrc_ch4", "dante_asrc_ch5", "dante_asrc_ch6"]
|
"dante_asrc_ch4", "dante_asrc_ch5", "dante_asrc_ch6"]
|
||||||
dante_channel_labels = ["CH1", "CH2", "CH3", "CH4", "CH5", "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)
|
col_left, col_right = st.columns(2)
|
||||||
with col_left:
|
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
|
left_idx = dante_channel_options.index(saved_left) if saved_left in dante_channel_options else 0
|
||||||
dante_left_channel = st.selectbox(
|
dante_left_channel = st.selectbox(
|
||||||
"Left Channel",
|
"Left Channel",
|
||||||
@@ -803,7 +890,6 @@ else:
|
|||||||
help="Select the Dante ASRC channel for the left stereo channel"
|
help="Select the Dante ASRC channel for the left stereo channel"
|
||||||
)
|
)
|
||||||
with col_right:
|
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
|
right_idx = dante_channel_options.index(saved_right) if saved_right in dante_channel_options else 1
|
||||||
dante_right_channel = st.selectbox(
|
dante_right_channel = st.selectbox(
|
||||||
"Right Channel",
|
"Right Channel",
|
||||||
@@ -821,7 +907,22 @@ else:
|
|||||||
|
|
||||||
# Stream count dropdown for Radio 1 (disabled in stereo mode - forced to 1 stream at 48kHz)
|
# Stream count dropdown for Radio 1 (disabled in stereo mode - forced to 1 stream at 48kHz)
|
||||||
r1_stream_options = list(dante_stream_options.keys())
|
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
|
default_r1_idx = r1_stream_options.index(saved_r1_streams) if saved_r1_streams in r1_stream_options else 0
|
||||||
|
|
||||||
if dante_stereo_enabled:
|
if dante_stereo_enabled:
|
||||||
@@ -856,7 +957,17 @@ else:
|
|||||||
(r1_max_quality == "Fair (16kHz)" and quality == "Fair (16kHz)")):
|
(r1_max_quality == "Fair (16kHz)" and quality == "Fair (16kHz)")):
|
||||||
r1_available_qualities.append(quality)
|
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:
|
if saved_r1_quality not in r1_available_qualities:
|
||||||
saved_r1_quality = r1_max_quality
|
saved_r1_quality = r1_max_quality
|
||||||
|
|
||||||
@@ -875,7 +986,7 @@ else:
|
|||||||
with col_r1_flags1:
|
with col_r1_flags1:
|
||||||
r1_assisted_listening = st.checkbox(
|
r1_assisted_listening = st.checkbox(
|
||||||
"Assistive (R1)",
|
"Assistive (R1)",
|
||||||
value=bool(saved_r1_config.get('assisted_listening', False)),
|
value=bool(saved_settings.get('assisted_listening_stream', False)),
|
||||||
disabled=is_streaming,
|
disabled=is_streaming,
|
||||||
help="Assistive listening stream"
|
help="Assistive listening stream"
|
||||||
)
|
)
|
||||||
@@ -883,13 +994,13 @@ else:
|
|||||||
with col_r1_flags2:
|
with col_r1_flags2:
|
||||||
r1_immediate_rendering = st.checkbox(
|
r1_immediate_rendering = st.checkbox(
|
||||||
"Immediate (R1)",
|
"Immediate (R1)",
|
||||||
value=bool(saved_r1_config.get('immediate_rendering', False)),
|
value=bool(saved_settings.get('immediate_rendering', False)),
|
||||||
disabled=is_streaming,
|
disabled=is_streaming,
|
||||||
help="Ignore presentation delay"
|
help="Ignore presentation delay"
|
||||||
)
|
)
|
||||||
|
|
||||||
with col_r1_pdelay:
|
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))
|
default_pdelay_ms = max(10, min(200, default_pdelay // 1000))
|
||||||
r1_presentation_delay_ms = st.number_input(
|
r1_presentation_delay_ms = st.number_input(
|
||||||
"Delay (ms, R1)",
|
"Delay (ms, R1)",
|
||||||
@@ -900,7 +1011,7 @@ else:
|
|||||||
|
|
||||||
with col_r1_qos:
|
with col_r1_qos:
|
||||||
qos_options = list(QOS_PRESET_MAP.keys())
|
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
|
default_qos_idx = qos_options.index(saved_qos) if saved_qos in qos_options else 0
|
||||||
r1_qos_preset = st.selectbox(
|
r1_qos_preset = st.selectbox(
|
||||||
"QoS (R1)", options=qos_options, index=default_qos_idx,
|
"QoS (R1)", options=qos_options, index=default_qos_idx,
|
||||||
@@ -918,8 +1029,15 @@ else:
|
|||||||
if dante_stereo_enabled:
|
if dante_stereo_enabled:
|
||||||
# Stereo mode: single stream with combined L+R channels
|
# Stereo mode: single stream with combined L+R channels
|
||||||
with st.expander("Stereo Stream - Radio 1", expanded=True):
|
with st.expander("Stereo Stream - Radio 1", expanded=True):
|
||||||
saved_streams = saved_r1_config.get('streams', [])
|
# Read from flat settings structure
|
||||||
saved_stream = saved_streams[0] if saved_streams else {}
|
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
|
# First row: Channel name and password
|
||||||
col_name, col_pwd = st.columns([2, 1])
|
col_name, col_pwd = st.columns([2, 1])
|
||||||
@@ -927,7 +1045,7 @@ else:
|
|||||||
with col_name:
|
with col_name:
|
||||||
stream_name = st.text_input(
|
stream_name = st.text_input(
|
||||||
"Channel Name",
|
"Channel Name",
|
||||||
value=saved_stream.get('name', 'Dante_Stereo'),
|
value=saved_name,
|
||||||
disabled=is_streaming,
|
disabled=is_streaming,
|
||||||
key="r1_stereo_name"
|
key="r1_stereo_name"
|
||||||
)
|
)
|
||||||
@@ -935,7 +1053,7 @@ else:
|
|||||||
with col_pwd:
|
with col_pwd:
|
||||||
stream_password = st.text_input(
|
stream_password = st.text_input(
|
||||||
"Stream Password",
|
"Stream Password",
|
||||||
value=saved_stream.get('stream_password', ''),
|
value=saved_password,
|
||||||
type="password",
|
type="password",
|
||||||
disabled=is_streaming,
|
disabled=is_streaming,
|
||||||
key="r1_stereo_password",
|
key="r1_stereo_password",
|
||||||
@@ -948,7 +1066,7 @@ else:
|
|||||||
with col_prog:
|
with col_prog:
|
||||||
program_info = st.text_input(
|
program_info = st.text_input(
|
||||||
"Program Info",
|
"Program Info",
|
||||||
value=saved_stream.get('program_info', 'Dante Stereo Broadcast'),
|
value=saved_program_info,
|
||||||
disabled=is_streaming,
|
disabled=is_streaming,
|
||||||
key="r1_stereo_program"
|
key="r1_stereo_program"
|
||||||
)
|
)
|
||||||
@@ -956,7 +1074,7 @@ else:
|
|||||||
with col_lang_code:
|
with col_lang_code:
|
||||||
language = st.text_input(
|
language = st.text_input(
|
||||||
"Language",
|
"Language",
|
||||||
value=saved_stream.get('language', 'eng'),
|
value=saved_language,
|
||||||
disabled=is_streaming,
|
disabled=is_streaming,
|
||||||
key="r1_stereo_lang",
|
key="r1_stereo_lang",
|
||||||
help="ISO 639-3 language code"
|
help="ISO 639-3 language code"
|
||||||
@@ -990,10 +1108,21 @@ else:
|
|||||||
})
|
})
|
||||||
else:
|
else:
|
||||||
# Normal mono mode: multiple streams with individual channels
|
# 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):
|
for i in range(r1_num_streams):
|
||||||
with st.expander(f"Stream {i+1} - Radio 1", expanded=True):
|
with st.expander(f"Stream {i+1} - Radio 1", expanded=True):
|
||||||
saved_streams = saved_r1_config.get('streams', [])
|
# Get saved values from flat structure
|
||||||
saved_stream = saved_streams[i] if i < len(saved_streams) else {}
|
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
|
# First row: Channel name and language
|
||||||
col_name, col_lang = st.columns([2, 1])
|
col_name, col_lang = st.columns([2, 1])
|
||||||
@@ -1001,7 +1130,7 @@ else:
|
|||||||
with col_name:
|
with col_name:
|
||||||
stream_name = st.text_input(
|
stream_name = st.text_input(
|
||||||
f"Channel Name",
|
f"Channel Name",
|
||||||
value=saved_stream.get('name', f'Dante_R1_S{i+1}'),
|
value=saved_name,
|
||||||
disabled=is_streaming,
|
disabled=is_streaming,
|
||||||
key=f"r1_stream_{i}_name"
|
key=f"r1_stream_{i}_name"
|
||||||
)
|
)
|
||||||
@@ -1009,7 +1138,7 @@ else:
|
|||||||
with col_lang:
|
with col_lang:
|
||||||
stream_password = st.text_input(
|
stream_password = st.text_input(
|
||||||
f"Stream Password",
|
f"Stream Password",
|
||||||
value=saved_stream.get('stream_password', ''),
|
value=saved_password,
|
||||||
type="password",
|
type="password",
|
||||||
disabled=is_streaming,
|
disabled=is_streaming,
|
||||||
key=f"r1_stream_{i}_password",
|
key=f"r1_stream_{i}_password",
|
||||||
@@ -1022,7 +1151,7 @@ else:
|
|||||||
with col_prog:
|
with col_prog:
|
||||||
program_info = st.text_input(
|
program_info = st.text_input(
|
||||||
f"Program Info",
|
f"Program Info",
|
||||||
value=saved_stream.get('program_info', f'Dante Radio 1 Stream {i+1}'),
|
value=saved_program_info,
|
||||||
disabled=is_streaming,
|
disabled=is_streaming,
|
||||||
key=f"r1_stream_{i}_program"
|
key=f"r1_stream_{i}_program"
|
||||||
)
|
)
|
||||||
@@ -1030,7 +1159,7 @@ else:
|
|||||||
with col_lang_code:
|
with col_lang_code:
|
||||||
language = st.text_input(
|
language = st.text_input(
|
||||||
f"Language",
|
f"Language",
|
||||||
value=saved_stream.get('language', 'eng'),
|
value=saved_language,
|
||||||
disabled=is_streaming,
|
disabled=is_streaming,
|
||||||
key=f"r1_stream_{i}_lang",
|
key=f"r1_stream_{i}_lang",
|
||||||
help="ISO 639-3 language code"
|
help="ISO 639-3 language code"
|
||||||
@@ -1045,7 +1174,7 @@ else:
|
|||||||
|
|
||||||
if not is_streaming and input_options:
|
if not is_streaming and input_options:
|
||||||
# Get default from session state first, then from saved settings
|
# 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
|
default_input_label = None
|
||||||
for label, name in option_name_map.items():
|
for label, name in option_name_map.items():
|
||||||
if name == default_input_name:
|
if name == default_input_name:
|
||||||
@@ -1066,7 +1195,7 @@ else:
|
|||||||
st.session_state[device_session_key] = input_device
|
st.session_state[device_session_key] = input_device
|
||||||
else:
|
else:
|
||||||
# When streaming, get the device from session state
|
# 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
|
# Convert internal name to display label
|
||||||
display_label = current_device
|
display_label = current_device
|
||||||
@@ -1097,13 +1226,17 @@ else:
|
|||||||
st.subheader("Radio 2")
|
st.subheader("Radio 2")
|
||||||
|
|
||||||
# Disable Radio 2 in stereo mode
|
# Disable Radio 2 in stereo mode
|
||||||
saved_r2_config = saved_settings.get('dante_radio2', {})
|
secondary_settings = saved_settings.get('secondary', {})
|
||||||
if dante_stereo_enabled:
|
if dante_stereo_enabled:
|
||||||
st.info("🎧 Radio 2 is automatically disabled in stereo mode")
|
st.info("🎧 Radio 2 is automatically disabled in stereo mode")
|
||||||
radio2_enabled = False
|
radio2_enabled = False
|
||||||
else:
|
else:
|
||||||
# Enable/disable checkbox for Radio 2
|
# Enable/disable checkbox for Radio 2
|
||||||
|
# Use saved settings or streaming state to determine default
|
||||||
radio2_enabled_default = secondary_is_streaming
|
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(
|
radio2_enabled = st.checkbox(
|
||||||
"Enable Radio 2",
|
"Enable Radio 2",
|
||||||
value=radio2_enabled_default,
|
value=radio2_enabled_default,
|
||||||
@@ -1114,7 +1247,22 @@ else:
|
|||||||
if radio2_enabled:
|
if radio2_enabled:
|
||||||
# Stream count dropdown for Radio 2
|
# Stream count dropdown for Radio 2
|
||||||
r2_stream_options = r1_stream_options
|
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
|
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(
|
r2_stream_config = st.selectbox(
|
||||||
@@ -1137,7 +1285,16 @@ else:
|
|||||||
(r2_max_quality == "Fair (16kHz)" and quality == "Fair (16kHz)")):
|
(r2_max_quality == "Fair (16kHz)" and quality == "Fair (16kHz)")):
|
||||||
r2_available_qualities.append(quality)
|
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:
|
if saved_r2_quality not in r2_available_qualities:
|
||||||
saved_r2_quality = r2_max_quality
|
saved_r2_quality = r2_max_quality
|
||||||
|
|
||||||
@@ -1150,13 +1307,12 @@ else:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Radio-level settings for Radio 2
|
# 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")
|
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:
|
with col_r2_flags1:
|
||||||
r2_assisted_listening = st.checkbox(
|
r2_assisted_listening = st.checkbox(
|
||||||
"Assistive (R2)",
|
"Assistive (R2)",
|
||||||
value=bool(saved_r2_config.get('assisted_listening', False)),
|
value=bool(secondary_settings.get('assisted_listening_stream', False)),
|
||||||
disabled=is_streaming,
|
disabled=is_streaming,
|
||||||
help="Assistive listening stream"
|
help="Assistive listening stream"
|
||||||
)
|
)
|
||||||
@@ -1164,13 +1320,13 @@ else:
|
|||||||
with col_r2_flags2:
|
with col_r2_flags2:
|
||||||
r2_immediate_rendering = st.checkbox(
|
r2_immediate_rendering = st.checkbox(
|
||||||
"Immediate (R2)",
|
"Immediate (R2)",
|
||||||
value=bool(saved_r2_config.get('immediate_rendering', False)),
|
value=bool(secondary_settings.get('immediate_rendering', False)),
|
||||||
disabled=is_streaming,
|
disabled=is_streaming,
|
||||||
help="Ignore presentation delay"
|
help="Ignore presentation delay"
|
||||||
)
|
)
|
||||||
|
|
||||||
with col_r2_pdelay:
|
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))
|
default_pdelay_ms = max(10, min(200, default_pdelay // 1000))
|
||||||
r2_presentation_delay_ms = st.number_input(
|
r2_presentation_delay_ms = st.number_input(
|
||||||
"Delay (ms, R2)",
|
"Delay (ms, R2)",
|
||||||
@@ -1181,10 +1337,10 @@ else:
|
|||||||
|
|
||||||
with col_r2_qos:
|
with col_r2_qos:
|
||||||
qos_options = list(QOS_PRESET_MAP.keys())
|
qos_options = list(QOS_PRESET_MAP.keys())
|
||||||
saved_qos = saved_r2_config.get('qos_preset', 'Fast')
|
saved_qos = secondary_settings.get('qos_preset', 'Fast')
|
||||||
default_qos_idx = qos_options.index(saved_qos) if saved_qos in qos_options else 0
|
default_qos_idx2 = qos_options.index(saved_qos) if saved_qos in qos_options else 0
|
||||||
r2_qos_preset = st.selectbox(
|
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,
|
disabled=is_streaming,
|
||||||
help="Quality of Service preset for Radio 2"
|
help="Quality of Service preset for Radio 2"
|
||||||
)
|
)
|
||||||
@@ -1193,10 +1349,21 @@ else:
|
|||||||
st.write("**Stream Configuration (Radio 2)**")
|
st.write("**Stream Configuration (Radio 2)**")
|
||||||
r2_streams = []
|
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):
|
for i in range(r2_num_streams):
|
||||||
with st.expander(f"Stream {i+1} - Radio 2", expanded=True):
|
with st.expander(f"Stream {i+1} - Radio 2", expanded=True):
|
||||||
saved_streams = saved_r2_config.get('streams', [])
|
# Get saved values from flat secondary structure
|
||||||
saved_stream = saved_streams[i] if i < len(saved_streams) else {}
|
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
|
# First row: Channel name and password
|
||||||
col_name, col_pwd = st.columns([2, 1])
|
col_name, col_pwd = st.columns([2, 1])
|
||||||
@@ -1204,7 +1371,7 @@ else:
|
|||||||
with col_name:
|
with col_name:
|
||||||
stream_name = st.text_input(
|
stream_name = st.text_input(
|
||||||
f"Channel Name",
|
f"Channel Name",
|
||||||
value=saved_stream.get('name', f'Dante_R2_S{i+1}'),
|
value=saved_name2,
|
||||||
disabled=is_streaming,
|
disabled=is_streaming,
|
||||||
key=f"r2_stream_{i}_name"
|
key=f"r2_stream_{i}_name"
|
||||||
)
|
)
|
||||||
@@ -1212,7 +1379,7 @@ else:
|
|||||||
with col_pwd:
|
with col_pwd:
|
||||||
stream_password = st.text_input(
|
stream_password = st.text_input(
|
||||||
f"Stream Password",
|
f"Stream Password",
|
||||||
value=saved_stream.get('stream_password', ''),
|
value=saved_password2,
|
||||||
type="password",
|
type="password",
|
||||||
disabled=is_streaming,
|
disabled=is_streaming,
|
||||||
key=f"r2_stream_{i}_password",
|
key=f"r2_stream_{i}_password",
|
||||||
@@ -1225,7 +1392,7 @@ else:
|
|||||||
with col_prog:
|
with col_prog:
|
||||||
program_info = st.text_input(
|
program_info = st.text_input(
|
||||||
f"Program Info",
|
f"Program Info",
|
||||||
value=saved_stream.get('program_info', f'Dante Radio 2 Stream {i+1}'),
|
value=saved_program_info2,
|
||||||
disabled=is_streaming,
|
disabled=is_streaming,
|
||||||
key=f"r2_stream_{i}_program"
|
key=f"r2_stream_{i}_program"
|
||||||
)
|
)
|
||||||
@@ -1233,7 +1400,7 @@ else:
|
|||||||
with col_lang:
|
with col_lang:
|
||||||
language = st.text_input(
|
language = st.text_input(
|
||||||
f"Language",
|
f"Language",
|
||||||
value=saved_stream.get('language', 'eng'),
|
value=saved_language2,
|
||||||
disabled=is_streaming,
|
disabled=is_streaming,
|
||||||
key=f"r2_stream_{i}_lang",
|
key=f"r2_stream_{i}_lang",
|
||||||
help="ISO 639-3 language code"
|
help="ISO 639-3 language code"
|
||||||
@@ -1248,7 +1415,7 @@ else:
|
|||||||
|
|
||||||
if not is_streaming and input_options:
|
if not is_streaming and input_options:
|
||||||
# Get default from session state first, then from saved settings
|
# 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
|
default_input_label = None
|
||||||
for label, name in option_name_map.items():
|
for label, name in option_name_map.items():
|
||||||
if name == default_input_name:
|
if name == default_input_name:
|
||||||
@@ -1269,7 +1436,7 @@ else:
|
|||||||
st.session_state[device_session_key] = input_device
|
st.session_state[device_session_key] = input_device
|
||||||
else:
|
else:
|
||||||
# When streaming, get the device from session state
|
# 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
|
# Convert internal name to display label
|
||||||
display_label = current_device
|
display_label = current_device
|
||||||
@@ -1354,8 +1521,30 @@ else:
|
|||||||
|
|
||||||
if audio_mode in ("USB", "Network"):
|
if audio_mode in ("USB", "Network"):
|
||||||
# USB/Network: single set of controls shared with the single channel
|
# 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())
|
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(
|
quality = st.selectbox(
|
||||||
"Stream Quality (Sampling Rate)",
|
"Stream Quality (Sampling Rate)",
|
||||||
quality_options,
|
quality_options,
|
||||||
@@ -1366,7 +1555,7 @@ else:
|
|||||||
|
|
||||||
stream_passwort = st.text_input(
|
stream_passwort = st.text_input(
|
||||||
"Stream Passwort",
|
"Stream Passwort",
|
||||||
value="",
|
value=saved_pwd,
|
||||||
type="password",
|
type="password",
|
||||||
disabled=is_streaming,
|
disabled=is_streaming,
|
||||||
help="Optional: Set a broadcast code to protect your stream. Leave empty for an open (uncoded) broadcast."
|
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),
|
analog_gain_db_right=cfg.get('analog_gain_db_right', 0.0),
|
||||||
bigs=[
|
bigs=[
|
||||||
auracast_config.AuracastBigConfig(
|
auracast_config.AuracastBigConfig(
|
||||||
code=(cfg['stream_passwort'].strip() or None),
|
code=((cfg['stream_passwort'] or '').strip() or None),
|
||||||
name=cfg['name'],
|
name=cfg['name'],
|
||||||
program_info=cfg['program_info'],
|
program_info=cfg['program_info'],
|
||||||
language=cfg['language'],
|
language=cfg['language'],
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from datetime import datetime
|
|||||||
import asyncio
|
import asyncio
|
||||||
import random
|
import random
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import threading
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
from fastapi import FastAPI, HTTPException
|
from fastapi import FastAPI, HTTPException
|
||||||
@@ -208,6 +209,28 @@ multicaster1: multicast_control.Multicaster | None = None
|
|||||||
multicaster2: multicast_control.Multicaster | None = None
|
multicaster2: multicast_control.Multicaster | None = None
|
||||||
_stream_lock = asyncio.Lock() # serialize initialize/stop_audio on API side
|
_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:
|
async def _init_i2c_on_startup() -> None:
|
||||||
# Ensure i2c-dev kernel module is loaded (required for /dev/i2c-* access)
|
# 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 ''
|
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'
|
||||||
|
# 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 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:'):
|
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
|
||||||
@@ -574,6 +602,7 @@ async def init_radio(transport: str, conf: auracast_config.AuracastConfigGroup,
|
|||||||
'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,
|
||||||
'input_device': input_device_name,
|
'input_device': input_device_name,
|
||||||
|
'input_devices': original_input_devices,
|
||||||
'program_info': [getattr(big, 'program_info', None) for big in conf.bigs],
|
'program_info': [getattr(big, 'program_info', None) for big in conf.bigs],
|
||||||
'gain': [getattr(big, 'input_gain', 1.0) 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,
|
'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")
|
@app.post("/init")
|
||||||
async def initialize(conf: auracast_config.AuracastConfigGroup):
|
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:
|
async with _stream_lock:
|
||||||
global multicaster1, global_config_group
|
global multicaster1, global_config_group
|
||||||
mc, persisted = await init_radio(TRANSPORT1, conf, multicaster1)
|
mc, persisted = await init_radio(TRANSPORT1, conf, multicaster1)
|
||||||
@@ -612,7 +644,10 @@ async def initialize(conf: auracast_config.AuracastConfigGroup):
|
|||||||
|
|
||||||
@app.post("/init2")
|
@app.post("/init2")
|
||||||
async def initialize2(conf: auracast_config.AuracastConfigGroup):
|
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:
|
async with _stream_lock:
|
||||||
global multicaster2
|
global multicaster2
|
||||||
mc, persisted = await init_radio(TRANSPORT2, conf, multicaster2)
|
mc, persisted = await init_radio(TRANSPORT2, conf, multicaster2)
|
||||||
@@ -631,7 +666,11 @@ async def set_led_enabled(body: dict):
|
|||||||
|
|
||||||
@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 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:
|
try:
|
||||||
was_running = await _stop_all()
|
was_running = await _stop_all()
|
||||||
|
|
||||||
@@ -681,9 +720,9 @@ async def set_adc_gain(payload: dict):
|
|||||||
|
|
||||||
@app.post("/stream_lc3")
|
@app.post("/stream_lc3")
|
||||||
async def send_audio(audio_data: dict[str, str]):
|
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:
|
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"}
|
return {"status": "audio_sent"}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
@@ -1058,6 +1097,19 @@ async def _autostart_from_settings():
|
|||||||
await do_primary()
|
await do_primary()
|
||||||
await do_secondary()
|
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")
|
@app.on_event("startup")
|
||||||
async def _startup_autostart_event():
|
async def _startup_autostart_event():
|
||||||
# Spawn the autostart task without blocking startup
|
# 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
|
# Hydrate settings cache once to avoid disk I/O during /status
|
||||||
_load_led_settings()
|
_load_led_settings()
|
||||||
_init_settings_cache_from_disk()
|
_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()
|
refresh_pw_cache()
|
||||||
log.info("[STARTUP] Scheduling autostart task")
|
# I2C init, ADC setup and the autostart task must run on the BLE loop so
|
||||||
asyncio.create_task(_autostart_from_settings())
|
# 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")
|
@app.get("/audio_inputs_pw_usb")
|
||||||
async def audio_inputs_pw_usb():
|
async def audio_inputs_pw_usb():
|
||||||
@@ -1154,6 +1205,9 @@ async def refresh_audio_devices():
|
|||||||
@app.post("/shutdown")
|
@app.post("/shutdown")
|
||||||
async def shutdown():
|
async def shutdown():
|
||||||
"""Stops broadcasting and releases all audio/Bluetooth resources."""
|
"""Stops broadcasting and releases all audio/Bluetooth resources."""
|
||||||
|
return await _on_ble_loop(_shutdown_impl())
|
||||||
|
|
||||||
|
async def _shutdown_impl():
|
||||||
try:
|
try:
|
||||||
await _stop_all()
|
await _stop_all()
|
||||||
return {"status": "stopped"}
|
return {"status": "stopped"}
|
||||||
@@ -1166,6 +1220,9 @@ async def system_reboot():
|
|||||||
|
|
||||||
Requires the service user to have passwordless sudo permissions to run '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:
|
try:
|
||||||
# Best-effort: stop any active streaming cleanly WITHOUT persisting state
|
# Best-effort: stop any active streaming cleanly WITHOUT persisting state
|
||||||
try:
|
try:
|
||||||
@@ -1189,46 +1246,26 @@ async def system_reboot():
|
|||||||
|
|
||||||
@app.post("/restart_dep")
|
@app.post("/restart_dep")
|
||||||
async def 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:
|
try:
|
||||||
# Get the dep directory path (dep.sh is in dante_package subdirectory)
|
log.info("Restarting DEP via systemctl...")
|
||||||
dep_dir = os.path.join(os.path.dirname(__file__), '..', '..', 'dep', 'dante_package')
|
proc = await asyncio.create_subprocess_exec(
|
||||||
|
"sudo", "systemctl", "restart", "dep.service",
|
||||||
# Run dep.sh stop first
|
|
||||||
log.info("Stopping DEP...")
|
|
||||||
stop_process = await asyncio.create_subprocess_exec(
|
|
||||||
"sudo", "bash", "dep.sh", "stop",
|
|
||||||
cwd=dep_dir,
|
|
||||||
stdout=asyncio.subprocess.PIPE,
|
stdout=asyncio.subprocess.PIPE,
|
||||||
stderr=asyncio.subprocess.PIPE
|
stderr=asyncio.subprocess.PIPE
|
||||||
)
|
)
|
||||||
stop_stdout, stop_stderr = await stop_process.communicate()
|
stdout, stderr = await proc.communicate()
|
||||||
|
|
||||||
if stop_process.returncode != 0:
|
if proc.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:
|
|
||||||
log.info("DEP restarted successfully")
|
log.info("DEP restarted successfully")
|
||||||
return {"status": "success", "message": "DEP restarted successfully"}
|
return {"status": "success", "message": "DEP restarted successfully"}
|
||||||
else:
|
else:
|
||||||
error_msg = start_stderr.decode() if start_stderr else "Unknown error"
|
error_msg = stderr.decode() if stderr else "Unknown error"
|
||||||
log.error(f"Failed to start DEP: {error_msg}")
|
log.error(f"Failed to restart DEP: {error_msg}")
|
||||||
raise HTTPException(status_code=500, detail=f"Failed to start DEP: {error_msg}")
|
raise HTTPException(status_code=500, detail=f"Failed to restart DEP: {error_msg}")
|
||||||
|
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
@@ -1322,6 +1359,9 @@ async def check_update():
|
|||||||
@app.post("/system_update")
|
@app.post("/system_update")
|
||||||
async def system_update():
|
async def system_update():
|
||||||
"""Update application: git pull main branch (latest tag), poetry install, restart services."""
|
"""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:
|
try:
|
||||||
# Best-effort: stop any active streaming cleanly
|
# Best-effort: stop any active streaming cleanly
|
||||||
try:
|
try:
|
||||||
@@ -1789,5 +1829,170 @@ if __name__ == '__main__':
|
|||||||
level=os.environ.get('LOG_LEVEL', log.INFO),
|
level=os.environ.get('LOG_LEVEL', log.INFO),
|
||||||
format='%(module)s.py:%(lineno)d %(levelname)s: %(message)s'
|
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
|
# 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)
|
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",
|
"$schema": "./dante.json_schema.json",
|
||||||
"platform":
|
"platform":
|
||||||
{
|
{
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
48000
|
48000
|
||||||
],
|
],
|
||||||
"samplesPerPeriod" : 16,
|
"samplesPerPeriod" : 16,
|
||||||
"periodsPerBuffer" : 300,
|
"periodsPerBuffer" : 150,
|
||||||
"networkLatencyMinMs" : 2,
|
"networkLatencyMinMs" : 2,
|
||||||
"networkLatencyDefaultMs" : 5,
|
"networkLatencyDefaultMs" : 5,
|
||||||
"supportedEncodings" :
|
"supportedEncodings" :
|
||||||
@@ -24,7 +24,10 @@
|
|||||||
"PCM16"
|
"PCM16"
|
||||||
],
|
],
|
||||||
"defaultEncoding" : "PCM16",
|
"defaultEncoding" : "PCM16",
|
||||||
"numDepCores" : 1
|
"numDepCores" :
|
||||||
|
[
|
||||||
|
3
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"network" :
|
"network" :
|
||||||
{
|
{
|
||||||
@@ -50,31 +53,32 @@
|
|||||||
"alsaAsrc":
|
"alsaAsrc":
|
||||||
{
|
{
|
||||||
"enableAlsaAsrc": true,
|
"enableAlsaAsrc": true,
|
||||||
|
"cpuAffinity": 3,
|
||||||
"deviceConfigurations": [
|
"deviceConfigurations": [
|
||||||
{
|
{
|
||||||
"deviceIdentifier": "hw:0,0",
|
"deviceIdentifier": "hw:6,0,0",
|
||||||
"direction": "playback",
|
"direction": "playback",
|
||||||
"bitDepth": 16,
|
"bitDepth": 16,
|
||||||
"numOpenChannels": 6,
|
"numOpenChannels": 6,
|
||||||
"alsaChannelRange": "0-5",
|
"alsaChannelRange": "0-5",
|
||||||
"danteChannelRange": "0-5",
|
"danteChannelRange": "0-5",
|
||||||
"bufferSize": 4800,
|
"bufferSize": 960,
|
||||||
"samplesPerPeriod": 16
|
"samplesPerPeriod": 16
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"product" :
|
"product" :
|
||||||
{
|
{
|
||||||
"manfId" : "Audinate",
|
"manfId" : "SummitFC",
|
||||||
"manfName" : "Audinate Pty Ltd",
|
"manfName" : "Summitwave FlexCo",
|
||||||
"modelId" : "OEMDEP",
|
"modelId" : "TX",
|
||||||
"modelName" : "Linux Dante Embedded Platform",
|
"modelName" : "Summitwave TX",
|
||||||
"modelVersion" :
|
"modelVersion" :
|
||||||
{
|
{
|
||||||
"major" : 9,
|
"major" : 1,
|
||||||
"minor" : 9,
|
"minor" : 0,
|
||||||
"bugfix" : 99
|
"bugfix" : 0
|
||||||
},
|
},
|
||||||
"devicePrefix" : "DEP"
|
"devicePrefix" : "SW-TX"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
#!/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.
|
||||||
|
# Avahi is reloaded on each event — no /etc/avahi/hosts file, avahi uses
|
||||||
|
# natural per-interface advertisement so each segment gets the right IP.
|
||||||
|
#
|
||||||
|
# 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"
|
||||||
|
# Only handle ethernet interfaces
|
||||||
|
if [[ ! "$INTERFACE" =~ ^eth ]]; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
reload_avahi() {
|
||||||
|
systemctl reload avahi-daemon 2>/dev/null || systemctl restart avahi-daemon 2>/dev/null
|
||||||
|
logger -t nm-link-local "[$INTERFACE] $ACTION — avahi reloaded"
|
||||||
|
}
|
||||||
|
|
||||||
|
case "$ACTION" in
|
||||||
|
up)
|
||||||
|
# On 'up' the interface may still carry a stale DHCP address from the previous
|
||||||
|
# session (NM hasn't cleaned it up yet). Reading ip-addr here is unreliable.
|
||||||
|
# Always re-enable link-local as a clean slate; let dhcp4-change suppress it
|
||||||
|
# later if a real DHCP lease is obtained.
|
||||||
|
logger -t nm-link-local "[$INTERFACE] Up — ensuring link-local active (clean slate)"
|
||||||
|
(sleep 2 && nmcli device modify "$INTERFACE" ipv4.link-local enabled 2>/dev/null \
|
||||||
|
&& logger -t nm-link-local "[$INTERFACE] Link-local explicitly enabled on up") &
|
||||||
|
reload_avahi
|
||||||
|
;;
|
||||||
|
|
||||||
|
dhcp4-change)
|
||||||
|
# dhcp4-change fires only when DHCP actually succeeds (new/renewed lease).
|
||||||
|
# At this point the DHCP IP is reliably present — safe to read and suppress link-local.
|
||||||
|
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 confirmed — suppressing link-local (session only)"
|
||||||
|
# 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") &
|
||||||
|
fi
|
||||||
|
reload_avahi
|
||||||
|
;;
|
||||||
|
|
||||||
|
down)
|
||||||
|
# NOTE: a carrier-change does NOT fully reset session-level 'device modify' state.
|
||||||
|
# The re-enable is therefore handled in the 'up' handler when no DHCP is detected.
|
||||||
|
logger -t nm-link-local "[$INTERFACE] Down — link-local will be re-enabled on next up without DHCP"
|
||||||
|
reload_avahi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
@@ -10,6 +10,8 @@ WorkingDirectory=/home/caster/bumble-auracast/src/auracast/server
|
|||||||
ExecStart=/home/caster/bumble-auracast/src/auracast/server/start_frontend_https.sh
|
ExecStart=/home/caster/bumble-auracast/src/auracast/server/start_frontend_https.sh
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
Environment=LOG_LEVEL=INFO
|
Environment=LOG_LEVEL=INFO
|
||||||
|
AllowedCPUs=0
|
||||||
|
CPUAffinity=0
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ ExecStart=/home/caster/bumble-auracast/.venv/bin/python src/auracast/multicast_s
|
|||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
Environment=PYTHONUNBUFFERED=1
|
Environment=PYTHONUNBUFFERED=1
|
||||||
Environment=LOG_LEVEL=INFO
|
Environment=LOG_LEVEL=INFO
|
||||||
|
AllowedCPUs=0
|
||||||
|
CPUAffinity=0
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=default.target
|
WantedBy=default.target
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
[Unit]
|
[Unit]
|
||||||
Description=Auracast Backend Server
|
Description=Auracast Backend Server
|
||||||
After=network.target
|
After=network.target dep.service
|
||||||
|
Wants=dep.service
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=simple
|
Type=simple
|
||||||
@@ -10,8 +11,10 @@ Restart=on-failure
|
|||||||
Environment=PYTHONUNBUFFERED=1
|
Environment=PYTHONUNBUFFERED=1
|
||||||
Environment=LOG_LEVEL=INFO
|
Environment=LOG_LEVEL=INFO
|
||||||
CPUSchedulingPolicy=fifo
|
CPUSchedulingPolicy=fifo
|
||||||
CPUSchedulingPriority=99
|
CPUSchedulingPriority=10
|
||||||
LimitRTPRIO=99
|
LimitRTPRIO=99
|
||||||
|
AllowedCPUs=0,1,2
|
||||||
|
CPUAffinity=0,1,2
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=default.target
|
WantedBy=default.target
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
ExecStart=/usr/bin/pipewire-aes67 -c /home/caster/bumble-auracast/src/service/aes67/pipewire-aes67.conf
|
||||||
Restart=always
|
Restart=always
|
||||||
RestartSec=5s
|
RestartSec=5s
|
||||||
|
AllowedCPUs=0
|
||||||
|
CPUAffinity=0
|
||||||
# Avoid StartLimitHit on quick failures during boot; let RestartSec handle pacing
|
# Avoid StartLimitHit on quick failures during boot; let RestartSec handle pacing
|
||||||
StartLimitIntervalSec=0
|
StartLimitIntervalSec=0
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ After=network.target
|
|||||||
Type=simple
|
Type=simple
|
||||||
ExecStart=/usr/sbin/ptp4l -i eth0 -f /home/caster/bumble-auracast/src/service/aes67/ptp_aes67_1.conf
|
ExecStart=/usr/sbin/ptp4l -i eth0 -f /home/caster/bumble-auracast/src/service/aes67/ptp_aes67_1.conf
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
|
AllowedCPUs=0
|
||||||
|
CPUAffinity=0
|
||||||
StandardOutput=journal
|
StandardOutput=journal
|
||||||
StandardError=journal
|
StandardError=journal
|
||||||
|
|
||||||
|
|||||||
@@ -8,30 +8,43 @@ set -e
|
|||||||
# Enable link-local for all wired ethernet connections
|
# Enable link-local for all wired ethernet connections
|
||||||
while IFS=: read -r name type; do
|
while IFS=: read -r name type; do
|
||||||
if [[ "$type" == *"ethernet"* ]]; then
|
if [[ "$type" == *"ethernet"* ]]; then
|
||||||
echo "Enabling IPv4 link-local for connection: $name"
|
echo "Configuring connection: $name"
|
||||||
|
# link-local: always enabled so direct-connect (no DHCP) works immediately
|
||||||
sudo nmcli connection modify "$name" ipv4.link-local enabled 2>/dev/null || echo "Failed to modify $name"
|
sudo nmcli connection modify "$name" ipv4.link-local enabled 2>/dev/null || echo "Failed to modify $name"
|
||||||
|
# may-fail=yes: do NOT tear down the connection when DHCP times out.
|
||||||
|
# Without this, NM declares ip-config-unavailable after the 45s DHCP timeout
|
||||||
|
# and enters a reconnect loop that causes ~1.5 min outages every ~45 seconds.
|
||||||
|
sudo nmcli connection modify "$name" ipv4.may-fail yes 2>/dev/null || echo "Failed to set may-fail on $name"
|
||||||
|
# Infinite DHCP timeout: NM keeps retrying DHCP in the background but never
|
||||||
|
# declares ip-config-unavailable. This prevents the 45s reconnect loop that
|
||||||
|
# kills the link-local address in direct-connect (no DHCP server) scenarios.
|
||||||
|
sudo nmcli connection modify "$name" ipv4.dhcp-timeout infinity 2>/dev/null || echo "Failed to set dhcp-timeout on $name"
|
||||||
sudo nmcli connection up "$name" 2>/dev/null || echo "Failed to bring up $name"
|
sudo nmcli connection up "$name" 2>/dev/null || echo "Failed to bring up $name"
|
||||||
fi
|
fi
|
||||||
done < <(nmcli -t -f NAME,TYPE connection show)
|
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
|
# Remove stale avahi hosts pin — this file overrides per-interface advertisement
|
||||||
echo "DHCP address detected: $DHCP_IP, configuring Avahi to prefer it for mDNS."
|
# and causes mDNS to always resolve to eth0's IP regardless of which interface
|
||||||
# Add entry to /etc/avahi/hosts to explicitly map hostname to DHCP IP
|
# the query arrived on, breaking eth1 mDNS entirely.
|
||||||
sudo mkdir -p /etc/avahi
|
sudo rm -f /etc/avahi/hosts
|
||||||
echo "$DHCP_IP $HOSTNAME $HOSTNAME.local" | sudo tee /etc/avahi/hosts > /dev/null
|
sudo systemctl restart avahi-daemon
|
||||||
# Restart avahi to apply the hosts file
|
|
||||||
sudo systemctl restart avahi-daemon
|
# Ensure Loopback is loaded with a fixed name and index
|
||||||
else
|
# Needed for dante
|
||||||
echo "No DHCP address detected, mDNS will use link local"
|
# TODO image when we create the next image this should be part of it
|
||||||
# Remove hosts file to let Avahi advertise all IPs
|
echo "options snd-aloop index=6 id=Loopback pcm_substreams=6" | sudo tee /etc/modprobe.d/snd-aloop.conf
|
||||||
sudo rm -f /etc/avahi/hosts
|
echo snd-aloop | sudo tee /etc/modules-load.d/snd-aloop.conf
|
||||||
sudo systemctl restart avahi-daemon
|
|
||||||
fi
|
|
||||||
|
|
||||||
|
# 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
|
# Copy system service file for frontend
|
||||||
sudo cp /home/caster/bumble-auracast/src/service/auracast-frontend.service /etc/systemd/system/auracast-frontend.service
|
sudo cp /home/caster/bumble-auracast/src/service/auracast-frontend.service /etc/systemd/system/auracast-frontend.service
|
||||||
@@ -40,20 +53,25 @@ sudo cp /home/caster/bumble-auracast/src/service/auracast-frontend.service /etc/
|
|||||||
mkdir -p /home/caster/.config/systemd/user
|
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
|
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
|
sudo systemctl daemon-reload
|
||||||
# Reload user systemd for server
|
# Reload user systemd for server
|
||||||
systemctl --user daemon-reload
|
systemctl --user daemon-reload
|
||||||
|
|
||||||
|
# Enable DEP to start on boot (system)
|
||||||
|
sudo systemctl enable dep.service
|
||||||
# Enable frontend to start on boot (system)
|
# Enable frontend to start on boot (system)
|
||||||
sudo systemctl enable auracast-frontend.service
|
sudo systemctl enable auracast-frontend.service
|
||||||
# Enable server to start on boot (user)
|
# Enable server to start on boot (user)
|
||||||
systemctl --user enable auracast-server.service
|
systemctl --user enable auracast-server.service
|
||||||
|
|
||||||
# Restart both
|
# Restart all
|
||||||
|
sudo systemctl restart dep.service
|
||||||
|
|
||||||
sudo systemctl restart auracast-frontend.service
|
sudo systemctl restart auracast-frontend.service
|
||||||
systemctl --user restart auracast-server.service
|
systemctl --user restart auracast-server.service
|
||||||
|
|
||||||
#print status
|
#print status
|
||||||
|
sudo systemctl status dep.service --no-pager
|
||||||
sudo systemctl status auracast-frontend.service --no-pager
|
sudo systemctl status auracast-frontend.service --no-pager
|
||||||
systemctl --user status auracast-server.service --no-pager
|
systemctl --user status auracast-server.service --no-pager
|
||||||
|
|||||||
Reference in New Issue
Block a user