Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ed64397189 | |||
| 50761a4b37 | |||
| 5bb31e3f6a | |||
| edd23fc115 | |||
| 19a01e404c | |||
| efb55050c0 |
@@ -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
|
||||||
|
|||||||
@@ -34,8 +34,6 @@ class AuracastGlobalConfig(BaseModel):
|
|||||||
octets_per_frame: int = 40 #48kbps@24kHz # bitrate = octets_per_frame * 8 / frame len
|
octets_per_frame: int = 40 #48kbps@24kHz # bitrate = octets_per_frame * 8 / frame len
|
||||||
frame_duration_us: int = 10000
|
frame_duration_us: int = 10000
|
||||||
presentation_delay_us: int = 40000
|
presentation_delay_us: int = 40000
|
||||||
# TODO:pydantic does not support bytes serialization - use .hex and np.fromhex()
|
|
||||||
manufacturer_data: tuple[int, bytes] | tuple[None, None] = (None, None)
|
|
||||||
# LE Audio: Broadcast Audio Immediate Rendering (metadata type 0x09)
|
# LE Audio: Broadcast Audio Immediate Rendering (metadata type 0x09)
|
||||||
# When true, include a zero-length LTV with type 0x09 in the subgroup metadata
|
# When true, include a zero-length LTV with type 0x09 in the subgroup metadata
|
||||||
# so receivers may render earlier than the presentation delay for lower latency.
|
# so receivers may render earlier than the presentation delay for lower latency.
|
||||||
@@ -81,7 +79,7 @@ class AuracastBigConfigDeu(AuracastBigConfig):
|
|||||||
name: str = 'Hörsaal A'
|
name: str = 'Hörsaal A'
|
||||||
language: str ='deu'
|
language: str ='deu'
|
||||||
program_info: str = 'Vorlesung DE'
|
program_info: str = 'Vorlesung DE'
|
||||||
audio_source: str = 'file:./testdata/wave_particle_5min_de.wav'
|
audio_source: str = 'file:./testdata/wave_particle_5min_de.lc3'
|
||||||
|
|
||||||
class AuracastBigConfigEng(AuracastBigConfig):
|
class AuracastBigConfigEng(AuracastBigConfig):
|
||||||
id: int = 123
|
id: int = 123
|
||||||
@@ -89,7 +87,7 @@ class AuracastBigConfigEng(AuracastBigConfig):
|
|||||||
name: str = 'Lecture Hall A'
|
name: str = 'Lecture Hall A'
|
||||||
language: str ='eng'
|
language: str ='eng'
|
||||||
program_info: str = 'Lecture EN'
|
program_info: str = 'Lecture EN'
|
||||||
audio_source: str = 'file:./testdata/wave_particle_5min_en.wav'
|
audio_source: str = 'file:./testdata/wave_particle_5min_en.lc3'
|
||||||
|
|
||||||
class AuracastBigConfigFra(AuracastBigConfig):
|
class AuracastBigConfigFra(AuracastBigConfig):
|
||||||
id: int = 1234
|
id: int = 1234
|
||||||
@@ -98,7 +96,7 @@ class AuracastBigConfigFra(AuracastBigConfig):
|
|||||||
name: str = 'Auditoire A'
|
name: str = 'Auditoire A'
|
||||||
language: str ='fra'
|
language: str ='fra'
|
||||||
program_info: str = 'Auditoire FR'
|
program_info: str = 'Auditoire FR'
|
||||||
audio_source: str = 'file:./testdata/wave_particle_5min_fr.wav'
|
audio_source: str = 'file:./testdata/wave_particle_5min_fr.lc3'
|
||||||
|
|
||||||
class AuracastBigConfigSpa(AuracastBigConfig):
|
class AuracastBigConfigSpa(AuracastBigConfig):
|
||||||
id: int =12345
|
id: int =12345
|
||||||
@@ -106,7 +104,7 @@ class AuracastBigConfigSpa(AuracastBigConfig):
|
|||||||
name: str = 'Auditorio A'
|
name: str = 'Auditorio A'
|
||||||
language: str ='spa'
|
language: str ='spa'
|
||||||
program_info: str = 'Auditorio ES'
|
program_info: str = 'Auditorio ES'
|
||||||
audio_source: str = 'file:./testdata/wave_particle_5min_es.wav'
|
audio_source: str = 'file:./testdata/wave_particle_5min_es.lc3'
|
||||||
|
|
||||||
class AuracastBigConfigIta(AuracastBigConfig):
|
class AuracastBigConfigIta(AuracastBigConfig):
|
||||||
id: int =1234567
|
id: int =1234567
|
||||||
@@ -114,7 +112,7 @@ class AuracastBigConfigIta(AuracastBigConfig):
|
|||||||
name: str = 'Aula A'
|
name: str = 'Aula A'
|
||||||
language: str ='ita'
|
language: str ='ita'
|
||||||
program_info: str = 'Aula IT'
|
program_info: str = 'Aula IT'
|
||||||
audio_source: str = 'file:./testdata/wave_particle_5min_it.wav'
|
audio_source: str = 'file:./testdata/wave_particle_5min_it.lc3'
|
||||||
|
|
||||||
|
|
||||||
class AuracastBigConfigPol(AuracastBigConfig):
|
class AuracastBigConfigPol(AuracastBigConfig):
|
||||||
@@ -123,7 +121,7 @@ class AuracastBigConfigPol(AuracastBigConfig):
|
|||||||
name: str = 'Sala Wykładowa'
|
name: str = 'Sala Wykładowa'
|
||||||
language: str ='pol'
|
language: str ='pol'
|
||||||
program_info: str = 'Sala Wykładowa PL'
|
program_info: str = 'Sala Wykładowa PL'
|
||||||
audio_source: str = 'file:./testdata/wave_particle_5min_pl.wav'
|
audio_source: str = 'file:./testdata/wave_particle_5min_pl.lc3'
|
||||||
|
|
||||||
|
|
||||||
class AuracastConfigGroup(AuracastGlobalConfig):
|
class AuracastConfigGroup(AuracastGlobalConfig):
|
||||||
|
|||||||
+33
-25
@@ -207,7 +207,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:
|
||||||
@@ -463,21 +463,6 @@ async def init_broadcast(
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
logger.info('Setup Advertising')
|
logger.info('Setup Advertising')
|
||||||
advertising_manufacturer_data = (
|
|
||||||
b''
|
|
||||||
if global_config.manufacturer_data == (None, None)
|
|
||||||
else bytes(
|
|
||||||
core.AdvertisingData(
|
|
||||||
[
|
|
||||||
(
|
|
||||||
core.AdvertisingData.MANUFACTURER_SPECIFIC_DATA,
|
|
||||||
struct.pack('<H', global_config.manufacturer_data[0])
|
|
||||||
+ global_config.manufacturer_data[1],
|
|
||||||
)
|
|
||||||
]
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
bigs[f'big{i}']['broadcast_audio_announcement'] = bap.BroadcastAudioAnnouncement(conf.id)
|
bigs[f'big{i}']['broadcast_audio_announcement'] = bap.BroadcastAudioAnnouncement(conf.id)
|
||||||
|
|
||||||
# Build advertising data types list
|
# Build advertising data types list
|
||||||
@@ -536,7 +521,6 @@ async def init_broadcast(
|
|||||||
advertising_data=(
|
advertising_data=(
|
||||||
bigs[f'big{i}']['broadcast_audio_announcement'].get_advertising_data()
|
bigs[f'big{i}']['broadcast_audio_announcement'].get_advertising_data()
|
||||||
+ bytes(core.AdvertisingData(advertising_data_types))
|
+ bytes(core.AdvertisingData(advertising_data_types))
|
||||||
+ advertising_manufacturer_data
|
|
||||||
),
|
),
|
||||||
periodic_advertising_parameters=bumble.device.PeriodicAdvertisingParameters(
|
periodic_advertising_parameters=bumble.device.PeriodicAdvertisingParameters(
|
||||||
periodic_advertising_interval_min=80,
|
periodic_advertising_interval_min=80,
|
||||||
@@ -655,6 +639,29 @@ async def init_broadcast(
|
|||||||
return bigs
|
return bigs
|
||||||
|
|
||||||
|
|
||||||
|
def _lc3_file_byte_gen(filename: str, loop: bool = False):
|
||||||
|
"""Stream LC3 frames from disk as individual bytes, with optional looping.
|
||||||
|
|
||||||
|
Yields one byte (int) at a time so it is compatible with the existing
|
||||||
|
``bytes(itertools.islice(gen, bytes_per_frame))`` consumer without loading
|
||||||
|
the whole file into memory.
|
||||||
|
"""
|
||||||
|
while True:
|
||||||
|
with open(filename, 'rb') as f:
|
||||||
|
f.read(18) # skip 18-byte LC3 header
|
||||||
|
while True:
|
||||||
|
size_b = f.read(2)
|
||||||
|
if len(size_b) < 2:
|
||||||
|
break
|
||||||
|
frame_size = struct.unpack('=H', size_b)[0]
|
||||||
|
frame = f.read(frame_size)
|
||||||
|
if len(frame) < frame_size:
|
||||||
|
break
|
||||||
|
yield from frame
|
||||||
|
if not loop:
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
class Streamer():
|
class Streamer():
|
||||||
"""
|
"""
|
||||||
Streamer class that supports multiple input formats. See bumble for streaming from wav or device
|
Streamer class that supports multiple input formats. See bumble for streaming from wav or device
|
||||||
@@ -810,13 +817,7 @@ class Streamer():
|
|||||||
big['precoded'] = True
|
big['precoded'] = True
|
||||||
big['lc3_bytes_per_frame'] = global_config.octets_per_frame
|
big['lc3_bytes_per_frame'] = global_config.octets_per_frame
|
||||||
filename = big_config[i].audio_source.replace('file:', '')
|
filename = big_config[i].audio_source.replace('file:', '')
|
||||||
|
big['lc3_frames'] = _lc3_file_byte_gen(filename, loop=big_config[i].loop)
|
||||||
lc3_bytes = read_lc3_file(filename)
|
|
||||||
lc3_frames = iter(lc3_bytes)
|
|
||||||
|
|
||||||
if big_config[i].loop:
|
|
||||||
lc3_frames = itertools.cycle(lc3_frames)
|
|
||||||
big['lc3_frames'] = lc3_frames
|
|
||||||
|
|
||||||
# use wav files and code them entirely before streaming
|
# use wav files and code them entirely before streaming
|
||||||
elif big_config[i].precode_wav and big_config[i].audio_source.endswith('.wav'):
|
elif big_config[i].precode_wav and big_config[i].audio_source.endswith('.wav'):
|
||||||
@@ -864,7 +865,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
|
||||||
@@ -933,6 +938,9 @@ class Streamer():
|
|||||||
if lc3_frame == b'': # Not all streams may stop at the same time
|
if lc3_frame == b'': # Not all streams may stop at the same time
|
||||||
stream_finished[i] = True
|
stream_finished[i] = True
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
for q_idx in range(big.get('num_bis', 1)):
|
||||||
|
await big['iso_queues'][q_idx].write(lc3_frame)
|
||||||
else: # code lc3 on the fly with perf counters
|
else: # code lc3 on the fly with perf counters
|
||||||
# Ensure frames generator exists (so we can aclose() on stop)
|
# Ensure frames generator exists (so we can aclose() on stop)
|
||||||
frames_gen = big.get('frames_gen')
|
frames_gen = big.get('frames_gen')
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -381,6 +385,17 @@ if audio_mode == "Demo":
|
|||||||
disabled=is_streaming,
|
disabled=is_streaming,
|
||||||
help="Select the demo stream configuration."
|
help="Select the demo stream configuration."
|
||||||
)
|
)
|
||||||
|
demo_content_options = ["Program material", "1 kHz test tone"]
|
||||||
|
saved_demo_content = saved_settings.get('demo_content', 'Program material')
|
||||||
|
if saved_demo_content not in demo_content_options:
|
||||||
|
saved_demo_content = 'Program material'
|
||||||
|
demo_content = st.selectbox(
|
||||||
|
"Demo Content",
|
||||||
|
demo_content_options,
|
||||||
|
index=demo_content_options.index(saved_demo_content),
|
||||||
|
disabled=is_streaming,
|
||||||
|
help="Select whether demo streams use program audio files or a continuous 1 kHz test tone."
|
||||||
|
)
|
||||||
# Stream password and flags (same as USB/AES67)
|
# Stream password and flags (same as USB/AES67)
|
||||||
saved_pwd = saved_settings.get('stream_password', '') or ''
|
saved_pwd = saved_settings.get('stream_password', '') or ''
|
||||||
stream_passwort = st.text_input(
|
stream_passwort = st.text_input(
|
||||||
@@ -479,13 +494,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,
|
||||||
@@ -496,7 +541,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."
|
||||||
@@ -610,7 +655,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,
|
||||||
@@ -659,22 +707,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."
|
||||||
@@ -742,7 +821,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,
|
||||||
@@ -752,7 +835,7 @@ else:
|
|||||||
else:
|
else:
|
||||||
input_device2 = None
|
input_device2 = None
|
||||||
else:
|
else:
|
||||||
input_device2 = saved_settings.get('input_device')
|
input_device2 = saved_settings.get('secondary', {}).get('input_device')
|
||||||
st.selectbox(
|
st.selectbox(
|
||||||
"Input Device (Radio 2)",
|
"Input Device (Radio 2)",
|
||||||
[input_device2 or "No device selected"],
|
[input_device2 or "No device selected"],
|
||||||
@@ -836,10 +919,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
|
||||||
)
|
)
|
||||||
@@ -848,13 +936,23 @@ else:
|
|||||||
dante_left_channel = None
|
dante_left_channel = None
|
||||||
dante_right_channel = None
|
dante_right_channel = None
|
||||||
if dante_stereo_enabled:
|
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_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",
|
||||||
@@ -865,7 +963,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",
|
||||||
@@ -875,7 +972,7 @@ else:
|
|||||||
disabled=is_streaming,
|
disabled=is_streaming,
|
||||||
help="Select the Dante ASRC channel for the right stereo channel"
|
help="Select the Dante ASRC channel for the right stereo channel"
|
||||||
)
|
)
|
||||||
|
|
||||||
if dante_left_channel == dante_right_channel:
|
if dante_left_channel == dante_right_channel:
|
||||||
st.warning("⚠️ Left and right channels are the same. Select different channels for true stereo.")
|
st.warning("⚠️ Left and right channels are the same. Select different channels for true stereo.")
|
||||||
else:
|
else:
|
||||||
@@ -883,7 +980,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:
|
||||||
@@ -913,15 +1025,25 @@ else:
|
|||||||
r1_available_qualities = []
|
r1_available_qualities = []
|
||||||
for quality in ["High (48kHz)", "Good (32kHz)", "Medium (24kHz)", "Fair (16kHz)"]:
|
for quality in ["High (48kHz)", "Good (32kHz)", "Medium (24kHz)", "Fair (16kHz)"]:
|
||||||
# Check if this quality is equal to or lower than the max
|
# 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 == "Medium (24kHz)" and quality in ["Medium (24kHz)", "Fair (16kHz)"]) or
|
||||||
(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
|
||||||
|
|
||||||
r1_radio_quality = st.selectbox(
|
r1_radio_quality = st.selectbox(
|
||||||
"Stream Quality (Radio 1)",
|
"Stream Quality (Radio 1)",
|
||||||
r1_available_qualities,
|
r1_available_qualities,
|
||||||
@@ -929,29 +1051,29 @@ else:
|
|||||||
disabled=is_streaming,
|
disabled=is_streaming,
|
||||||
help=f"Select stream quality for Radio 1. Maximum quality based on configuration: {r1_max_quality}"
|
help=f"Select stream quality for Radio 1. Maximum quality based on configuration: {r1_max_quality}"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Radio-level settings for Radio 1
|
# Radio-level settings for Radio 1
|
||||||
# First row: Assistive listening, immediate rendering, presentation delay, QoS
|
# 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")
|
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:
|
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"
|
||||||
)
|
)
|
||||||
|
|
||||||
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)",
|
||||||
@@ -959,10 +1081,10 @@ else:
|
|||||||
disabled=is_streaming,
|
disabled=is_streaming,
|
||||||
help="Presentation delay for Radio 1"
|
help="Presentation delay for Radio 1"
|
||||||
)
|
)
|
||||||
|
|
||||||
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,
|
||||||
@@ -973,7 +1095,7 @@ else:
|
|||||||
r1_tx_power = _tx_power_selectbox(
|
r1_tx_power = _tx_power_selectbox(
|
||||||
"TX Power (R1)",
|
"TX Power (R1)",
|
||||||
key="dante_tx_power_r1",
|
key="dante_tx_power_r1",
|
||||||
default=saved_r1_config.get('advertising_tx_power', saved_settings.get('advertising_tx_power', TX_POWER_DEFAULT)),
|
default=saved_settings.get('advertising_tx_power', TX_POWER_DEFAULT),
|
||||||
disabled=is_streaming,
|
disabled=is_streaming,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -987,24 +1109,31 @@ 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])
|
||||||
|
|
||||||
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"
|
||||||
)
|
)
|
||||||
|
|
||||||
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",
|
||||||
@@ -1013,19 +1142,19 @@ else:
|
|||||||
|
|
||||||
# Second row: Program info and language
|
# Second row: Program info and language
|
||||||
col_prog, col_lang_code = st.columns([2, 1])
|
col_prog, col_lang_code = st.columns([2, 1])
|
||||||
|
|
||||||
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"
|
||||||
)
|
)
|
||||||
|
|
||||||
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"
|
||||||
@@ -1059,47 +1188,58 @@ 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])
|
||||||
|
|
||||||
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"
|
||||||
)
|
)
|
||||||
|
|
||||||
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",
|
||||||
help="Optional: Set a broadcast code for this stream"
|
help="Optional: Set a broadcast code for this stream"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Second row: Program info and language
|
# Second row: Program info and language
|
||||||
col_prog, col_lang_code = st.columns([2, 1])
|
col_prog, col_lang_code = st.columns([2, 1])
|
||||||
|
|
||||||
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"
|
||||||
)
|
)
|
||||||
|
|
||||||
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"
|
||||||
@@ -1111,10 +1251,10 @@ else:
|
|||||||
with col_device:
|
with col_device:
|
||||||
# Session state key for persisting the selection
|
# Session state key for persisting the selection
|
||||||
device_session_key = f"r1_stream_{i}_device_saved"
|
device_session_key = f"r1_stream_{i}_device_saved"
|
||||||
|
|
||||||
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:
|
||||||
@@ -1122,7 +1262,7 @@ else:
|
|||||||
break
|
break
|
||||||
if default_input_label not in input_options and input_options:
|
if default_input_label not in input_options and input_options:
|
||||||
default_input_label = input_options[0]
|
default_input_label = input_options[0]
|
||||||
|
|
||||||
selected_option = st.selectbox(
|
selected_option = st.selectbox(
|
||||||
f"Input Device",
|
f"Input Device",
|
||||||
input_options,
|
input_options,
|
||||||
@@ -1135,7 +1275,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
|
||||||
@@ -1164,26 +1304,45 @@ else:
|
|||||||
# --- Radio 2 Section ---
|
# --- Radio 2 Section ---
|
||||||
with st.container(border=True):
|
with st.container(border=True):
|
||||||
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,
|
||||||
disabled=is_streaming,
|
disabled=is_streaming,
|
||||||
help="Activate a second Dante radio with its own quality and timing settings."
|
help="Activate a second Dante radio with its own quality and timing settings."
|
||||||
)
|
)
|
||||||
|
|
||||||
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(
|
||||||
@@ -1205,11 +1364,20 @@ else:
|
|||||||
(r2_max_quality == "Medium (24kHz)" and quality in ["Medium (24kHz)", "Fair (16kHz)"]) or
|
(r2_max_quality == "Medium (24kHz)" and quality in ["Medium (24kHz)", "Fair (16kHz)"]) or
|
||||||
(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
|
||||||
|
|
||||||
r2_radio_quality = st.selectbox(
|
r2_radio_quality = st.selectbox(
|
||||||
"Stream Quality (Radio 2)",
|
"Stream Quality (Radio 2)",
|
||||||
r2_available_qualities,
|
r2_available_qualities,
|
||||||
@@ -1217,29 +1385,28 @@ else:
|
|||||||
disabled=is_streaming,
|
disabled=is_streaming,
|
||||||
help=f"Select stream quality for Radio 2. Maximum quality based on configuration: {r2_max_quality}"
|
help=f"Select stream quality for Radio 2. Maximum quality based on configuration: {r2_max_quality}"
|
||||||
)
|
)
|
||||||
|
|
||||||
# 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"
|
||||||
)
|
)
|
||||||
|
|
||||||
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)",
|
||||||
@@ -1247,13 +1414,13 @@ else:
|
|||||||
disabled=is_streaming,
|
disabled=is_streaming,
|
||||||
help="Presentation delay for Radio 2"
|
help="Presentation delay for Radio 2"
|
||||||
)
|
)
|
||||||
|
|
||||||
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"
|
||||||
)
|
)
|
||||||
@@ -1261,55 +1428,66 @@ else:
|
|||||||
r2_tx_power = _tx_power_selectbox(
|
r2_tx_power = _tx_power_selectbox(
|
||||||
"TX Power (R2)",
|
"TX Power (R2)",
|
||||||
key="dante_tx_power_r2",
|
key="dante_tx_power_r2",
|
||||||
default=saved_r2_config.get('advertising_tx_power', saved_settings.get('secondary', {}).get('advertising_tx_power', TX_POWER_DEFAULT)),
|
default=saved_settings.get('secondary', {}).get('advertising_tx_power', TX_POWER_DEFAULT),
|
||||||
disabled=is_streaming,
|
disabled=is_streaming,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Per-stream configuration for Radio 2
|
# Per-stream configuration for Radio 2
|
||||||
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])
|
||||||
|
|
||||||
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"
|
||||||
)
|
)
|
||||||
|
|
||||||
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",
|
||||||
help="Optional: Set a broadcast code for this stream"
|
help="Optional: Set a broadcast code for this stream"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Second row: Program info and language
|
# Second row: Program info and language
|
||||||
col_prog, col_lang = st.columns([2, 1])
|
col_prog, col_lang = st.columns([2, 1])
|
||||||
|
|
||||||
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"
|
||||||
)
|
)
|
||||||
|
|
||||||
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"
|
||||||
@@ -1321,10 +1499,10 @@ else:
|
|||||||
with col_device:
|
with col_device:
|
||||||
# Session state key for persisting the selection
|
# Session state key for persisting the selection
|
||||||
device_session_key = f"r2_stream_{i}_device_saved"
|
device_session_key = f"r2_stream_{i}_device_saved"
|
||||||
|
|
||||||
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:
|
||||||
@@ -1332,7 +1510,7 @@ else:
|
|||||||
break
|
break
|
||||||
if default_input_label not in input_options and input_options:
|
if default_input_label not in input_options and input_options:
|
||||||
default_input_label = input_options[0]
|
default_input_label = input_options[0]
|
||||||
|
|
||||||
selected_option = st.selectbox(
|
selected_option = st.selectbox(
|
||||||
f"Input Device",
|
f"Input Device",
|
||||||
input_options,
|
input_options,
|
||||||
@@ -1345,7 +1523,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
|
||||||
@@ -1433,8 +1611,29 @@ 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
|
||||||
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]
|
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 = ''
|
||||||
|
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,
|
||||||
@@ -1445,7 +1644,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."
|
||||||
@@ -1623,12 +1822,22 @@ if start_stream:
|
|||||||
bigs1 = []
|
bigs1 = []
|
||||||
for i in range(demo_cfg['streams']):
|
for i in range(demo_cfg['streams']):
|
||||||
cfg_cls, lang = lang_cfgs[i % len(lang_cfgs)]
|
cfg_cls, lang = lang_cfgs[i % len(lang_cfgs)]
|
||||||
|
if demo_content == "1 kHz test tone":
|
||||||
|
source_file = f'../testdata/test_tone_1k_{int(q["rate"]/1000)}kHz_mono.lc3'
|
||||||
|
big_kwargs = {
|
||||||
|
'name': 'test tone',
|
||||||
|
'program_info': '1khz',
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
source_file = f'../testdata/wave_particle_5min_{lang}_{int(q["rate"]/1000)}kHz_mono.lc3'
|
||||||
|
big_kwargs = {}
|
||||||
bigs1.append(cfg_cls(
|
bigs1.append(cfg_cls(
|
||||||
code=(stream_passwort.strip() or None),
|
code=(stream_passwort.strip() or None),
|
||||||
audio_source=f'file:../testdata/wave_particle_5min_{lang}_{int(q["rate"]/1000)}kHz_mono.wav',
|
audio_source=f'file:{source_file}',
|
||||||
iso_que_len=32,
|
iso_que_len=32,
|
||||||
sampling_frequency=q['rate'],
|
sampling_frequency=q['rate'],
|
||||||
octets_per_frame=q['octets'],
|
octets_per_frame=q['octets'],
|
||||||
|
**big_kwargs,
|
||||||
))
|
))
|
||||||
|
|
||||||
max_per_mc = {48000: 1, 24000: 2, 16000: 3}
|
max_per_mc = {48000: 1, 24000: 2, 16000: 3}
|
||||||
@@ -1707,7 +1916,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
|
||||||
@@ -564,6 +592,13 @@ async def init_radio(transport: str, conf: auracast_config.AuracastConfigGroup,
|
|||||||
demo_count = sum(1 for big in conf.bigs if isinstance(big.audio_source, str) and big.audio_source.startswith('file:'))
|
demo_count = sum(1 for big in conf.bigs if isinstance(big.audio_source, str) and big.audio_source.startswith('file:'))
|
||||||
demo_rate = int(conf.auracast_sampling_rate_hz or 0)
|
demo_rate = int(conf.auracast_sampling_rate_hz or 0)
|
||||||
demo_type = None
|
demo_type = None
|
||||||
|
demo_sources = [
|
||||||
|
str(b.audio_source)
|
||||||
|
for b in conf.bigs
|
||||||
|
if isinstance(b.audio_source, str) and b.audio_source.startswith('file:')
|
||||||
|
]
|
||||||
|
is_demo_tone = bool(demo_sources) and all('test_tone_1k_' in src for src in demo_sources)
|
||||||
|
demo_content = '1 kHz test tone' if is_demo_tone else 'Program material'
|
||||||
if demo_count > 0 and demo_rate > 0:
|
if demo_count > 0 and demo_rate > 0:
|
||||||
if demo_rate in (48000, 24000, 16000):
|
if demo_rate in (48000, 24000, 16000):
|
||||||
demo_type = f"{demo_count} × {demo_rate//1000}kHz"
|
demo_type = f"{demo_count} × {demo_rate//1000}kHz"
|
||||||
@@ -574,6 +609,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,
|
||||||
@@ -591,8 +627,9 @@ async def init_radio(transport: str, conf: auracast_config.AuracastConfigGroup,
|
|||||||
'big_random_addresses': [getattr(big, 'random_address', DEFAULT_RANDOM_ADDRESS) for big in conf.bigs],
|
'big_random_addresses': [getattr(big, 'random_address', DEFAULT_RANDOM_ADDRESS) for big in conf.bigs],
|
||||||
'demo_total_streams': demo_count,
|
'demo_total_streams': demo_count,
|
||||||
'demo_stream_type': demo_type,
|
'demo_stream_type': demo_type,
|
||||||
|
'demo_content': demo_content,
|
||||||
'is_streaming': auto_started,
|
'is_streaming': auto_started,
|
||||||
'demo_sources': [str(b.audio_source) for b in conf.bigs if isinstance(b.audio_source, str) and b.audio_source.startswith('file:')],
|
'demo_sources': demo_sources,
|
||||||
}
|
}
|
||||||
return mc, persisted
|
return mc, persisted
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
@@ -603,7 +640,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)
|
||||||
@@ -613,7 +653,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)
|
||||||
@@ -632,7 +675,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()
|
||||||
|
|
||||||
@@ -682,9 +729,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))
|
||||||
@@ -1067,6 +1114,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
|
||||||
@@ -1087,12 +1147,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():
|
||||||
@@ -1163,6 +1222,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"}
|
||||||
@@ -1175,6 +1237,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:
|
||||||
@@ -1198,47 +1263,27 @@ 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
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -1331,6 +1376,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:
|
||||||
@@ -1798,5 +1846,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)
|
||||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -21,12 +21,12 @@ def read_lc3_file(filepath):
|
|||||||
logging.info('frame_duration %s', frame_duration)
|
logging.info('frame_duration %s', frame_duration)
|
||||||
logging.info('stream_length %s', stream_length)
|
logging.info('stream_length %s', stream_length)
|
||||||
|
|
||||||
lc3_bytes= b''
|
chunks = []
|
||||||
while True:
|
while True:
|
||||||
b = f_lc3.read(2)
|
b = f_lc3.read(2)
|
||||||
if b == b'':
|
if b == b'':
|
||||||
break
|
break
|
||||||
lc3_frame_size = struct.unpack('=H', b)[0]
|
lc3_frame_size = struct.unpack('=H', b)[0]
|
||||||
lc3_bytes += f_lc3.read(lc3_frame_size)
|
chunks.append(f_lc3.read(lc3_frame_size))
|
||||||
|
|
||||||
return lc3_bytes
|
return b''.join(chunks)
|
||||||
@@ -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