7 Commits

Author SHA1 Message Date
pober ed64397189 fix: resolve UI rework regressions in Dante/Analog/Network modes
- Remove undefined saved_r1_config/saved_r2_config variables in Dante mode TX power fields
- Fix quality_options used before assignment in USB/Network mode
- Fix Radio 2 input device reading from primary instead of secondary settings while streaming

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 17:48:23 +02:00
pober 50761a4b37 bugfix/1025-local-link-lost-connection (#34)
Fixes the bug that local link loses connection after a few minutes.

Openproject:
#1025
#608

Reviewed-on: #34
2026-05-20 10:12:08 +00:00
pober 5bb31e3f6a bugfix/1087-UI-reset-refresh (#33)
Openproject:
#1087

Reviewed-on: #33
2026-05-20 10:01:13 +00:00
pober edd23fc115 feature/1040-dante-activation (#31)
Makes the activation of dante possible.
Fixes issues with local link for DANTE.
First implementation of audiopipeline in its own thread.
Prevents Frontend from interrupting the audio stream.

Openproject:
#1040
#1069
#652
#1041
#1063

Reviewed-on: #31
2026-05-20 09:54:35 +00:00
pober 19a01e404c Removes manufacturer data. (#30)
Reviewed-on: #30
2026-05-20 09:40:22 +00:00
pstruebi efb55050c0 feature/1khz_testtone (#27)
- 1kHz test tone added
- all audio file converted to lc3 to save space
- streaming loop for lc3 files fixed

@pober

Reviewed-on: #27
Co-authored-by: pstruebi <struebin.patrick@gmail.com>
Co-committed-by: pstruebi <struebin.patrick@gmail.com>
2026-05-19 13:21:48 +00:00
pstruebi c82a17016e implement changes for dynamic power setting (#28)
Implements power control for the radios, both radios independent.

Reviewed-on: #28
Co-authored-by: pstruebi <struebin.patrick@gmail.com>
Co-committed-by: pstruebi <struebin.patrick@gmail.com>
2026-05-19 12:33:50 +00:00
67 changed files with 11587 additions and 11328 deletions
+26 -9
View File
@@ -1,5 +1,11 @@
from typing import List from typing import List
from pydantic import BaseModel from pydantic import BaseModel, field_validator
# Discrete TX power levels (dBm) supported by the Nordic SoftDevice Controller
# for the nRF radio PA. The HCI controller will clamp requested values to the
# nearest supported step. The maximum is bounded by CONFIG_BT_CTLR_TX_PWR_*
# in the hci_uart firmware (currently +8 dBm).
TX_POWER_VALID = [8, 7, 6, 5, 4, 3, 2, 0, -4, -8, -12, -16, -20]
# Define some base to hold the relevant parameters # Define some base to hold the relevant parameters
class AuracastQoSConfig(BaseModel): class AuracastQoSConfig(BaseModel):
@@ -28,13 +34,24 @@ 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.
immediate_rendering: bool = False immediate_rendering: bool = False
assisted_listening_stream: bool = False assisted_listening_stream: bool = False
# Bluetooth advertising TX power for this radio in dBm (per advertising set).
# Sent through HCI_LE_Set_Extended_Advertising_Parameters; the SDC clamps to
# nearest supported hardware step and propagates to primary/secondary adv,
# the periodic advertising train and the BIS ISO PDUs.
advertising_tx_power: int = 8
@field_validator('advertising_tx_power')
@classmethod
def _snap_tx_power(cls, v: int) -> int:
# Snap to the nearest supported discrete step in TX_POWER_VALID.
if v in TX_POWER_VALID:
return v
return min(TX_POWER_VALID, key=lambda s: abs(s - v))
# "Audio input. " # "Audio input. "
# "'device' -> use the host's default sound input device, " # "'device' -> use the host's default sound input device, "
@@ -62,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
@@ -70,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
@@ -79,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
@@ -87,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
@@ -95,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):
@@ -104,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):
+81 -24
View File
@@ -49,6 +49,7 @@ import bumble.transport
import bumble.utils import bumble.utils
from bumble.device import Host, AdvertisingChannelMap from bumble.device import Host, AdvertisingChannelMap
from bumble.audio import io as audio_io from bumble.audio import io as audio_io
from bumble.vendor.zephyr.hci import HCI_Write_Tx_Power_Level_Command
from auracast import auracast_config from auracast import auracast_config
from auracast.utils.read_lc3_file import read_lc3_file from auracast.utils.read_lc3_file import read_lc3_file
@@ -462,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
@@ -519,13 +505,22 @@ async def init_broadcast(
advertising_sid=i, advertising_sid=i,
primary_advertising_phy=hci.Phy.LE_1M, # 2m phy config throws error - because for primary advertising channels, 1mbit is only supported primary_advertising_phy=hci.Phy.LE_1M, # 2m phy config throws error - because for primary advertising channels, 1mbit is only supported
secondary_advertising_phy=hci.Phy.LE_1M, # this is the secondary advertising beeing send on non advertising channels (extendend advertising) secondary_advertising_phy=hci.Phy.LE_1M, # this is the secondary advertising beeing send on non advertising channels (extendend advertising)
#advertising_tx_power= # tx power in dbm (max 20) # Pass NO_PREFERENCE (0x7F) here for two reasons:
# 1. The Nordic SoftDevice Controller ignores this field for
# advertising sets and always returns the compile-time
# CONFIG_BT_CTLR_TX_PWR_* value. The real TX power is
# applied via the Zephyr VS Write_Tx_Power_Level command
# issued right after create_advertising_set() returns.
# 2. Bumble's HCI metadata declares this field as 1-byte
# *unsigned* (a bumble bug — the BT spec defines it as
# signed int8), so negative values would raise
# "bytes must be in range(0, 256)" at serialization.
advertising_tx_power=hci.HCI_LE_Set_Extended_Advertising_Parameters_Command.TX_POWER_NO_PREFERENCE,
#secondary_advertising_max_skip=10, #secondary_advertising_max_skip=10,
), ),
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,
@@ -536,6 +531,48 @@ async def init_broadcast(
auto_start=True, auto_start=True,
) )
bigs[f'big{i}']['advertising_set'] = advertising_set bigs[f'big{i}']['advertising_set'] = advertising_set
# NOTE: selected_tx_power below reflects the SDC's compile-time max
# (LE_Set_Ext_Adv_Params was sent with NO_PREFERENCE). The actual
# transmit power is set by the VS Write_Tx_Power_Level call below.
logging.debug(
'LE_Set_Ext_Adv_Params reports controller fallback TX power: %+d dBm (handle=%d)',
getattr(advertising_set, 'selected_tx_power', 0),
i,
)
# The Nordic SoftDevice Controller does not honor the per-set
# advertising_tx_power passed in HCI_LE_Set_Extended_Advertising_Parameters
# (it returns the compile-time CONFIG_BT_CTLR_TX_PWR_* value regardless).
# Apply the requested level via the Zephyr Vendor-Specific HCI command
# Write_Tx_Power_Level (opcode 0xFC0E), which the SDC honors per
# advertising handle. The SDC clamps the value to the nearest supported
# hardware step (max bounded by CONFIG_BT_CTLR_TX_PWR_PLUS_8).
try:
adv_handle = getattr(advertising_set, 'advertising_handle', i)
response = await device.send_command(
HCI_Write_Tx_Power_Level_Command(
handle_type=HCI_Write_Tx_Power_Level_Command.TX_POWER_HANDLE_TYPE_ADV,
connection_handle=adv_handle,
tx_power_level=global_config.advertising_tx_power,
)
)
rp = getattr(response, 'return_parameters', None)
status = getattr(rp, 'status', 0xFF) if rp is not None else 0xFF
selected = getattr(rp, 'selected_tx_power_level', None) if rp is not None else None
if status == 0 and selected is not None:
logging.info(
'Advertising TX power (VS Write_Tx_Power_Level): requested=%+d dBm, controller selected=%+d dBm (handle=%d)',
global_config.advertising_tx_power,
selected,
adv_handle,
)
else:
logging.warning(
'VS Write_Tx_Power_Level failed: status=0x%02X handle=%d requested=%+d dBm',
status, adv_handle, global_config.advertising_tx_power,
)
except Exception as e:
logging.warning('VS Write_Tx_Power_Level not supported by controller: %s', e)
logging.info('Start Periodic Advertising') logging.info('Start Periodic Advertising')
await advertising_set.start_periodic() await advertising_set.start_periodic()
@@ -602,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
@@ -757,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'):
@@ -884,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')
+115 -4
View File
@@ -100,6 +100,36 @@ QOS_PRESET_MAP = {
"Robust": auracast_config.AuracastQosRobust(), "Robust": auracast_config.AuracastQosRobust(),
} }
# Discrete advertising TX power steps in dBm supported by the Nordic SDC radio
# PA. Sent through HCI_LE_Set_Extended_Advertising_Parameters; the controller
# clamps to the nearest hardware step.
TX_POWER_OPTIONS = [8, 7, 6, 5, 4, 3, 2, 0, -4, -8, -12, -16, -20]
TX_POWER_DEFAULT = 8
def _coerce_tx_power(value, default: int = TX_POWER_DEFAULT) -> int:
try:
v = int(value)
except (TypeError, ValueError):
return default
if v in TX_POWER_OPTIONS:
return v
return min(TX_POWER_OPTIONS, key=lambda s: abs(s - v))
def _tx_power_selectbox(label: str, key: str, default: int, disabled: bool, help_text: str | None = None) -> int:
snapped = _coerce_tx_power(default)
idx = TX_POWER_OPTIONS.index(snapped)
return st.selectbox(
label,
TX_POWER_OPTIONS,
index=idx,
key=key,
format_func=lambda v: f"{v:+d} dBm",
disabled=disabled,
help=help_text or "Bluetooth advertising TX power for this radio. Higher values increase range; lower values reduce interference and power draw.",
)
# Try loading persisted settings from backend # Try loading persisted settings from backend
saved_settings = {} saved_settings = {}
try: try:
@@ -355,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(
@@ -398,6 +439,22 @@ if audio_mode == "Demo":
disabled=is_streaming, disabled=is_streaming,
help="Fast: 2 retransmissions, lower latency. Robust: 4 retransmissions, better reliability." help="Fast: 2 retransmissions, lower latency. Robust: 4 retransmissions, better reliability."
) )
# Per-radio TX power for Demo (independent for R1 and R2)
col_tx_r1, col_tx_r2 = st.columns(2, gap="small")
with col_tx_r1:
tx_power_r1 = _tx_power_selectbox(
"TX Power (R1)",
key="demo_tx_power_r1",
default=saved_settings.get('advertising_tx_power', TX_POWER_DEFAULT),
disabled=is_streaming,
)
with col_tx_r2:
tx_power_r2 = _tx_power_selectbox(
"TX Power (R2)",
key="demo_tx_power_r2",
default=saved_settings.get('secondary', {}).get('advertising_tx_power', TX_POWER_DEFAULT),
disabled=is_streaming,
)
#st.info(f"Demo mode selected: {demo_selected} (Streams: {demo_stream_map[demo_selected]['streams']}, Rate: {demo_stream_map[demo_selected]['rate']} Hz)") #st.info(f"Demo mode selected: {demo_selected} (Streams: {demo_stream_map[demo_selected]['streams']}, Rate: {demo_stream_map[demo_selected]['rate']} Hz)")
quality = None # Not used in demo mode quality = None # Not used in demo mode
else: else:
@@ -524,6 +581,13 @@ else:
help="Fast: 2 retransmissions, lower latency. Robust: 4 retransmissions, better reliability." help="Fast: 2 retransmissions, lower latency. Robust: 4 retransmissions, better reliability."
) )
tx_power_r1 = _tx_power_selectbox(
"TX Power (R1)",
key="analog_tx_power_r1",
default=saved_settings.get('advertising_tx_power', TX_POWER_DEFAULT),
disabled=is_streaming,
)
col_r1_name, col_r1_lang = st.columns([2, 1]) col_r1_name, col_r1_lang = st.columns([2, 1])
with col_r1_name: with col_r1_name:
stream_name1 = st.text_input( stream_name1 = st.text_input(
@@ -726,6 +790,13 @@ else:
help="Fast: 2 retransmissions, lower latency. Robust: 4 retransmissions, better reliability." help="Fast: 2 retransmissions, lower latency. Robust: 4 retransmissions, better reliability."
) )
tx_power_r2 = _tx_power_selectbox(
"TX Power (R2)",
key="analog_tx_power_r2",
default=saved_settings.get('secondary', {}).get('advertising_tx_power', TX_POWER_DEFAULT),
disabled=is_streaming,
)
col_r2_name, col_r2_lang = st.columns([2, 1]) col_r2_name, col_r2_lang = st.columns([2, 1])
with col_r2_name: with col_r2_name:
stream_name2 = st.text_input( stream_name2 = st.text_input(
@@ -764,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"],
@@ -785,6 +856,7 @@ else:
'immediate_rendering': immediate_rendering2, 'immediate_rendering': immediate_rendering2,
'presentation_delay_ms': presentation_delay_ms2, 'presentation_delay_ms': presentation_delay_ms2,
'qos_preset': qos_preset2, 'qos_preset': qos_preset2,
'tx_power': tx_power_r2,
'analog_gain_db_left': analog_gain_db_left, 'analog_gain_db_left': analog_gain_db_left,
'analog_gain_db_right': analog_gain_db_right, 'analog_gain_db_right': analog_gain_db_right,
} }
@@ -801,6 +873,7 @@ else:
'immediate_rendering': immediate_rendering1, 'immediate_rendering': immediate_rendering1,
'presentation_delay_ms': presentation_delay_ms1, 'presentation_delay_ms': presentation_delay_ms1,
'qos_preset': qos_preset1, 'qos_preset': qos_preset1,
'tx_power': tx_power_r1,
'stereo_mode': stereo_enabled, 'stereo_mode': stereo_enabled,
'analog_gain_db_left': analog_gain_db_left, 'analog_gain_db_left': analog_gain_db_left,
'analog_gain_db_right': analog_gain_db_right, 'analog_gain_db_right': analog_gain_db_right,
@@ -1019,6 +1092,13 @@ else:
help="Quality of Service preset for Radio 1" help="Quality of Service preset for Radio 1"
) )
r1_tx_power = _tx_power_selectbox(
"TX Power (R1)",
key="dante_tx_power_r1",
default=saved_settings.get('advertising_tx_power', TX_POWER_DEFAULT),
disabled=is_streaming,
)
# Per-stream configuration for Radio 1 # Per-stream configuration for Radio 1
if dante_stereo_enabled: if dante_stereo_enabled:
st.write("**Stereo Stream Configuration (Radio 1)**") st.write("**Stereo Stream Configuration (Radio 1)**")
@@ -1345,6 +1425,13 @@ else:
help="Quality of Service preset for Radio 2" help="Quality of Service preset for Radio 2"
) )
r2_tx_power = _tx_power_selectbox(
"TX Power (R2)",
key="dante_tx_power_r2",
default=saved_settings.get('secondary', {}).get('advertising_tx_power', TX_POWER_DEFAULT),
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 = []
@@ -1471,6 +1558,7 @@ else:
r2_immediate_rendering = False r2_immediate_rendering = False
r2_presentation_delay_ms = 40 r2_presentation_delay_ms = 40
r2_qos_preset = 'Fast' r2_qos_preset = 'Fast'
r2_tx_power = TX_POWER_DEFAULT
# Validate unique input devices for Network - Dante mode # Validate unique input devices for Network - Dante mode
if audio_mode == "Network - Dante": if audio_mode == "Network - Dante":
@@ -1502,6 +1590,7 @@ else:
'immediate_rendering': r1_immediate_rendering, 'immediate_rendering': r1_immediate_rendering,
'presentation_delay_ms': r1_presentation_delay_ms, 'presentation_delay_ms': r1_presentation_delay_ms,
'qos_preset': r1_qos_preset, 'qos_preset': r1_qos_preset,
'tx_power': r1_tx_power,
'dante_stereo_mode': dante_stereo_enabled, 'dante_stereo_mode': dante_stereo_enabled,
'dante_stereo_left': dante_left_channel, 'dante_stereo_left': dante_left_channel,
'dante_stereo_right': dante_right_channel, 'dante_stereo_right': dante_right_channel,
@@ -1517,11 +1606,13 @@ else:
'immediate_rendering': r2_immediate_rendering if radio2_enabled else False, 'immediate_rendering': r2_immediate_rendering if radio2_enabled else False,
'presentation_delay_ms': r2_presentation_delay_ms if radio2_enabled else 40000, 'presentation_delay_ms': r2_presentation_delay_ms if radio2_enabled else 40000,
'qos_preset': r2_qos_preset if radio2_enabled else 'Fast', 'qos_preset': r2_qos_preset if radio2_enabled else 'Fast',
'tx_power': r2_tx_power if radio2_enabled else TX_POWER_DEFAULT,
} if radio2_enabled else None } if radio2_enabled else None
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 # Use saved settings if audio_mode matches, otherwise use defaults
quality_options = list(QUALITY_MAP.keys())
saved_audio_mode = saved_settings.get('audio_mode') saved_audio_mode = saved_settings.get('audio_mode')
if saved_audio_mode in ("USB", "Network"): if saved_audio_mode in ("USB", "Network"):
# Map saved sampling rate to quality label # Map saved sampling rate to quality label
@@ -1541,8 +1632,6 @@ else:
# Use defaults when switching from another mode # Use defaults when switching from another mode
default_quality = "Medium (24kHz)" if "Medium (24kHz)" in quality_options else quality_options[0] default_quality = "Medium (24kHz)" if "Medium (24kHz)" in quality_options else quality_options[0]
saved_pwd = '' saved_pwd = ''
quality_options = list(QUALITY_MAP.keys())
if default_quality not in quality_options: if default_quality not in quality_options:
default_quality = quality_options[0] default_quality = quality_options[0]
quality = st.selectbox( quality = st.selectbox(
@@ -1595,6 +1684,13 @@ else:
help="Fast: 2 retransmissions, lower latency. Robust: 4 retransmissions, better reliability." help="Fast: 2 retransmissions, lower latency. Robust: 4 retransmissions, better reliability."
) )
tx_power = _tx_power_selectbox(
"TX Power",
key="usb_tx_power",
default=saved_settings.get('advertising_tx_power', TX_POWER_DEFAULT),
disabled=is_streaming,
)
stream_name = st.text_input( stream_name = st.text_input(
"Channel Name", "Channel Name",
value=default_name, value=default_name,
@@ -1726,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}
@@ -1748,6 +1854,7 @@ if start_stream:
immediate_rendering=immediate_rendering, immediate_rendering=immediate_rendering,
presentation_delay_us=int(presentation_delay_ms * 1000), presentation_delay_us=int(presentation_delay_ms * 1000),
qos_config=QOS_PRESET_MAP[qos_preset], qos_config=QOS_PRESET_MAP[qos_preset],
advertising_tx_power=tx_power_r1,
bigs=bigs1 bigs=bigs1
) )
config2 = None config2 = None
@@ -1760,6 +1867,7 @@ if start_stream:
immediate_rendering=immediate_rendering, immediate_rendering=immediate_rendering,
presentation_delay_us=int(presentation_delay_ms * 1000), presentation_delay_us=int(presentation_delay_ms * 1000),
qos_config=QOS_PRESET_MAP[qos_preset], qos_config=QOS_PRESET_MAP[qos_preset],
advertising_tx_power=tx_power_r2,
bigs=bigs2 bigs=bigs2
) )
@@ -1803,6 +1911,7 @@ if start_stream:
immediate_rendering=bool(cfg['immediate_rendering']), immediate_rendering=bool(cfg['immediate_rendering']),
presentation_delay_us=int(cfg['presentation_delay_ms'] * 1000), presentation_delay_us=int(cfg['presentation_delay_ms'] * 1000),
qos_config=QOS_PRESET_MAP[cfg['qos_preset']], qos_config=QOS_PRESET_MAP[cfg['qos_preset']],
advertising_tx_power=int(cfg.get('tx_power', TX_POWER_DEFAULT)),
analog_gain_db_left=cfg.get('analog_gain_db_left', 0.0), analog_gain_db_left=cfg.get('analog_gain_db_left', 0.0),
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=[
@@ -1890,6 +1999,7 @@ if start_stream:
immediate_rendering=bool(radio_cfg['immediate_rendering']), immediate_rendering=bool(radio_cfg['immediate_rendering']),
presentation_delay_us=int(radio_cfg['presentation_delay_ms'] * 1000), presentation_delay_us=int(radio_cfg['presentation_delay_ms'] * 1000),
qos_config=QOS_PRESET_MAP[radio_cfg['qos_preset']], qos_config=QOS_PRESET_MAP[radio_cfg['qos_preset']],
advertising_tx_power=int(radio_cfg.get('tx_power', TX_POWER_DEFAULT)),
bigs=bigs bigs=bigs
) )
@@ -1925,6 +2035,7 @@ if start_stream:
immediate_rendering=immediate_rendering, immediate_rendering=immediate_rendering,
presentation_delay_us=int(presentation_delay_ms * 1000), presentation_delay_us=int(presentation_delay_ms * 1000),
qos_config=QOS_PRESET_MAP[qos_preset], qos_config=QOS_PRESET_MAP[qos_preset],
advertising_tx_power=tx_power,
bigs=[ bigs=[
auracast_config.AuracastBigConfig( auracast_config.AuracastBigConfig(
code=(stream_passwort.strip() or None), code=(stream_passwort.strip() or None),
+20 -3
View File
@@ -592,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"
@@ -614,13 +621,15 @@ async def init_radio(transport: str, conf: auracast_config.AuracastConfigGroup,
'analog_stereo_mode': getattr(conf.bigs[0], 'analog_stereo_mode', False) if conf.bigs else False, 'analog_stereo_mode': getattr(conf.bigs[0], 'analog_stereo_mode', False) if conf.bigs else False,
'analog_gain_db_left': getattr(conf, 'analog_gain_db_left', 0.0), 'analog_gain_db_left': getattr(conf, 'analog_gain_db_left', 0.0),
'analog_gain_db_right': getattr(conf, 'analog_gain_db_right', 0.0), 'analog_gain_db_right': getattr(conf, 'analog_gain_db_right', 0.0),
'advertising_tx_power': getattr(conf, 'advertising_tx_power', 8),
'stream_password': (conf.bigs[0].code if conf.bigs and getattr(conf.bigs[0], 'code', None) else None), 'stream_password': (conf.bigs[0].code if conf.bigs and getattr(conf.bigs[0], 'code', None) else None),
'big_ids': [getattr(big, 'id', DEFAULT_BIG_ID) for big in conf.bigs], 'big_ids': [getattr(big, 'id', DEFAULT_BIG_ID) for big in conf.bigs],
'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:
@@ -794,11 +803,12 @@ async def _autostart_from_settings():
big_ids = settings.get('big_ids') or [] big_ids = settings.get('big_ids') or []
big_addrs = settings.get('big_random_addresses') or [] big_addrs = settings.get('big_random_addresses') or []
stream_password = settings.get('stream_password') stream_password = settings.get('stream_password')
tx_power = int(settings.get('advertising_tx_power', 8))
original_ts = settings.get('timestamp') original_ts = settings.get('timestamp')
previously_streaming = bool(settings.get('is_streaming')) previously_streaming = bool(settings.get('is_streaming'))
log.info( log.info(
"[AUTOSTART][PRIMARY] loaded settings: previously_streaming=%s audio_mode=%s rate=%s octets=%s pres_delay=%s qos_preset=%s immediate_rendering=%s assisted_listening_stream=%s demo_sources=%s", "[AUTOSTART][PRIMARY] loaded settings: previously_streaming=%s audio_mode=%s rate=%s octets=%s pres_delay=%s qos_preset=%s immediate_rendering=%s assisted_listening_stream=%s tx_power=%+d dBm demo_sources=%s",
previously_streaming, previously_streaming,
audio_mode, audio_mode,
rate, rate,
@@ -807,6 +817,7 @@ async def _autostart_from_settings():
saved_qos_preset, saved_qos_preset,
immediate_rendering, immediate_rendering,
assisted_listening_stream, assisted_listening_stream,
tx_power,
(settings.get('demo_sources') or []), (settings.get('demo_sources') or []),
) )
@@ -856,6 +867,7 @@ async def _autostart_from_settings():
immediate_rendering=immediate_rendering, immediate_rendering=immediate_rendering,
assisted_listening_stream=assisted_listening_stream, assisted_listening_stream=assisted_listening_stream,
presentation_delay_us=pres_delay if pres_delay is not None else 40000, presentation_delay_us=pres_delay if pres_delay is not None else 40000,
advertising_tx_power=tx_power,
bigs=bigs, bigs=bigs,
) )
# Set num_bis for stereo mode if needed # Set num_bis for stereo mode if needed
@@ -925,6 +937,7 @@ async def _autostart_from_settings():
presentation_delay_us=pres_delay if pres_delay is not None else 40000, presentation_delay_us=pres_delay if pres_delay is not None else 40000,
analog_gain_db_left=settings.get('analog_gain_db_left', 0.0), analog_gain_db_left=settings.get('analog_gain_db_left', 0.0),
analog_gain_db_right=settings.get('analog_gain_db_right', 0.0), analog_gain_db_right=settings.get('analog_gain_db_right', 0.0),
advertising_tx_power=tx_power,
bigs=bigs, bigs=bigs,
) )
# Set num_bis for stereo mode if needed # Set num_bis for stereo mode if needed
@@ -960,10 +973,11 @@ async def _autostart_from_settings():
big_ids = settings.get('big_ids') or [] big_ids = settings.get('big_ids') or []
big_addrs = settings.get('big_random_addresses') or [] big_addrs = settings.get('big_random_addresses') or []
stream_password = settings.get('stream_password') stream_password = settings.get('stream_password')
tx_power = int(settings.get('advertising_tx_power', 8))
original_ts = settings.get('timestamp') original_ts = settings.get('timestamp')
previously_streaming = bool(settings.get('is_streaming')) previously_streaming = bool(settings.get('is_streaming'))
log.info( log.info(
"[AUTOSTART][SECONDARY] loaded settings: previously_streaming=%s audio_mode=%s rate=%s octets=%s pres_delay=%s qos_preset=%s immediate_rendering=%s assisted_listening_stream=%s demo_sources=%s", "[AUTOSTART][SECONDARY] loaded settings: previously_streaming=%s audio_mode=%s rate=%s octets=%s pres_delay=%s qos_preset=%s immediate_rendering=%s assisted_listening_stream=%s tx_power=%+d dBm demo_sources=%s",
previously_streaming, previously_streaming,
audio_mode, audio_mode,
rate, rate,
@@ -972,6 +986,7 @@ async def _autostart_from_settings():
saved_qos_preset, saved_qos_preset,
immediate_rendering, immediate_rendering,
assisted_listening_stream, assisted_listening_stream,
tx_power,
(settings.get('demo_sources') or []), (settings.get('demo_sources') or []),
) )
if not previously_streaming: if not previously_streaming:
@@ -1011,6 +1026,7 @@ async def _autostart_from_settings():
immediate_rendering=immediate_rendering, immediate_rendering=immediate_rendering,
assisted_listening_stream=assisted_listening_stream, assisted_listening_stream=assisted_listening_stream,
presentation_delay_us=pres_delay if pres_delay is not None else 40000, presentation_delay_us=pres_delay if pres_delay is not None else 40000,
advertising_tx_power=tx_power,
bigs=bigs, bigs=bigs,
) )
conf.qos_config = QOS_PRESET_MAP.get(saved_qos_preset, QOS_PRESET_MAP["Fast"]) conf.qos_config = QOS_PRESET_MAP.get(saved_qos_preset, QOS_PRESET_MAP["Fast"])
@@ -1080,6 +1096,7 @@ async def _autostart_from_settings():
presentation_delay_us=pres_delay if pres_delay is not None else 40000, presentation_delay_us=pres_delay if pres_delay is not None else 40000,
analog_gain_db_left=settings.get('analog_gain_db_left', 0.0), analog_gain_db_left=settings.get('analog_gain_db_left', 0.0),
analog_gain_db_right=settings.get('analog_gain_db_right', 0.0), analog_gain_db_right=settings.get('analog_gain_db_right', 0.0),
advertising_tx_power=tx_power,
bigs=bigs, bigs=bigs,
) )
conf.qos_config = QOS_PRESET_MAP.get(saved_qos_preset, QOS_PRESET_MAP["Fast"]) conf.qos_config = QOS_PRESET_MAP.get(saved_qos_preset, QOS_PRESET_MAP["Fast"])
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.
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.
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.
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.
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.
+3 -3
View File
@@ -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)
+11340 -11283
View File
File diff suppressed because it is too large Load Diff