make adjustments for bluetooth sig qualification

Co-authored-by: Paul Obernesser <paul.obernesser@inncubator.at>
Reviewed-on: https://gitea.pstruebi.xyz/auracaster/bumble-auracast/pulls/13
This commit was merged in pull request #13.
This commit is contained in:
2025-12-11 14:44:35 +01:00
parent 6c7b74a0b2
commit 45f058be46
37 changed files with 1490 additions and 64 deletions

View File

@@ -7,24 +7,19 @@ class AuracastQoSConfig(BaseModel):
number_of_retransmissions: int
max_transport_latency_ms: int
class AuracastQosHigh(AuracastQoSConfig):
class AuracastQosDefault(AuracastQoSConfig):
iso_int_multiple_10ms: int = 1
number_of_retransmissions:int = 4 #4
max_transport_latency_ms:int = 43 #varies from the default value in bumble (was 65)
class AuracastQosMid(AuracastQoSConfig):
iso_int_multiple_10ms: int = 2
number_of_retransmissions:int = 3
max_transport_latency_ms:int = 65
class AuracastQosLow(AuracastQoSConfig):
iso_int_multiple_10ms: int = 3
number_of_retransmissions:int = 2 #4
max_transport_latency_ms:int = 65 #varies from the default value in bumble (was 65)
class AuracastQosFast(AuracastQoSConfig):
iso_int_multiple_10ms: int = 1
number_of_retransmissions:int = 2
max_transport_latency_ms:int = 22
class AuracastGlobalConfig(BaseModel):
qos_config: AuracastQoSConfig = AuracastQosHigh()
qos_config: AuracastQoSConfig = AuracastQosDefault()
debug: bool = False
device_name: str = 'Auracaster'
transport: str = ''
@@ -59,6 +54,7 @@ class AuracastBigConfig(BaseModel):
loop: bool = True
precode_wav: bool = False
iso_que_len: int = 64
num_bis: int = 1 # 1 = mono (FRONT_LEFT), 2 = stereo (FRONT_LEFT + FRONT_RIGHT)
class AuracastBigConfigDeu(AuracastBigConfig):
id: int = 12
@@ -73,7 +69,7 @@ class AuracastBigConfigEng(AuracastBigConfig):
random_address: str = 'F2:F1:F2:F3:F4:F5'
name: str = 'Lecture Hall A'
language: str ='eng'
program_info: str = 'Lecture EN'
program_info: str = 'Lecture EN'
audio_source: str = 'file:./testdata/wave_particle_5min_en.wav'
class AuracastBigConfigFra(AuracastBigConfig):
@@ -82,7 +78,7 @@ class AuracastBigConfigFra(AuracastBigConfig):
# French
name: str = 'Auditoire A'
language: str ='fra'
program_info: str = 'Auditoire FR'
program_info: str = 'Auditoire FR'
audio_source: str = 'file:./testdata/wave_particle_5min_fr.wav'
class AuracastBigConfigSpa(AuracastBigConfig):
@@ -90,7 +86,7 @@ class AuracastBigConfigSpa(AuracastBigConfig):
random_address: str = 'F4:F1:F2:F3:F4:F5'
name: str = 'Auditorio A'
language: str ='spa'
program_info: str = 'Auditorio ES'
program_info: str = 'Auditorio ES'
audio_source: str = 'file:./testdata/wave_particle_5min_es.wav'
class AuracastBigConfigIta(AuracastBigConfig):
@@ -98,7 +94,7 @@ class AuracastBigConfigIta(AuracastBigConfig):
random_address: str = 'F5:F1:F2:F3:F4:F5'
name: str = 'Aula A'
language: str ='ita'
program_info: str = 'Aula IT'
program_info: str = 'Aula IT'
audio_source: str = 'file:./testdata/wave_particle_5min_it.wav'
@@ -107,7 +103,7 @@ class AuracastBigConfigPol(AuracastBigConfig):
random_address: str = 'F6:F1:F2:F3:F4:F5'
name: str = 'Sala Wykładowa'
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'

View File

@@ -257,6 +257,20 @@ def run_async(async_command: Coroutine) -> None:
color('!!! An error occurred while executing the command:', 'red'), message
)
def _build_bis_list(num_bis: int) -> list:
"""Build BIS list for BasicAudioAnnouncement based on num_bis (1=mono, 2=stereo)."""
locations = [bap.AudioLocation.FRONT_LEFT, bap.AudioLocation.FRONT_RIGHT]
return [
bap.BasicAudioAnnouncement.BIS(
index=idx + 1,
codec_specific_configuration=bap.CodecSpecificConfiguration(
audio_channel_allocation=locations[idx]
),
)
for idx in range(num_bis)
]
async def init_broadcast(
device,
global_config : auracast_config.AuracastGlobalConfig,
@@ -272,7 +286,8 @@ async def init_broadcast(
tag=le_audio.Metadata.Tag.LANGUAGE, data=conf.language.encode()
),
le_audio.Metadata.Entry(
tag=le_audio.Metadata.Tag.PROGRAM_INFO, data=conf.program_info.encode()
tag=le_audio.Metadata.Tag.PROGRAM_INFO,
data=conf.program_info.encode('latin-1')
),
le_audio.Metadata.Entry(
tag=le_audio.Metadata.Tag.BROADCAST_NAME, data=conf.name.encode()
@@ -295,9 +310,10 @@ async def init_broadcast(
else []
)
)
logging.info(
metadata.pretty_print("\n")
)
try:
logging.info(metadata.pretty_print("\n"))
except UnicodeDecodeError:
logging.info("Metadata: (contains non-UTF-8 bytes)")
bigs[f'big{i}'] = {}
# Config advertising set
bigs[f'big{i}']['basic_audio_announcement'] = bap.BasicAudioAnnouncement(
@@ -307,18 +323,11 @@ async def init_broadcast(
codec_id=hci.CodingFormat(codec_id=hci.CodecID.LC3),
codec_specific_configuration=bap.CodecSpecificConfiguration(
sampling_frequency=bap_sampling_freq,
frame_duration=bap.FrameDuration.DURATION_10000_US,
frame_duration=bap.FrameDuration.DURATION_7500_US if global_config.frame_duration_us == 7500 else bap.FrameDuration.DURATION_10000_US,
octets_per_codec_frame=global_config.octets_per_frame,
),
metadata=metadata,
bis=[
bap.BasicAudioAnnouncement.BIS(
index=1,
codec_specific_configuration=bap.CodecSpecificConfiguration(
audio_channel_allocation=bap.AudioLocation.FRONT_LEFT
),
),
],
bis=_build_bis_list(conf.num_bis),
)
],
)
@@ -339,6 +348,36 @@ async def init_broadcast(
)
)
bigs[f'big{i}']['broadcast_audio_announcement'] = bap.BroadcastAudioAnnouncement(conf.id)
# Build advertising data types list
advertising_data_types = [
(core.AdvertisingData.BROADCAST_NAME, conf.name.encode()),
]
# [PBP] Add Public Broadcast Profile Service Data (UUID 0x1856)
# Required for PTS Qualification (PBP/PBS/STR)
# Dynamically calculate PBP features based on stream configuration
pbp_features = 0x00
# Bit 0: Encryption (set if broadcast_code is configured)
if conf.code is not None:
pbp_features |= 0x01
# Bit 1 vs Bit 2: Quality based on sample rate
if global_config.auracast_sampling_rate_hz in [16000, 24000]:
pbp_features |= 0x02 # Standard Quality
elif global_config.auracast_sampling_rate_hz == 48000:
pbp_features |= 0x04 # High Quality
# Build PBP service data with Program_Info metadata (LTV format: Length, Type=0x03, Value)
# LTV: Length = 1 (type) + len(value), Type = 0x03 (Program_Info)
program_info_bytes = conf.program_info.encode('latin-1')
pbp_metadata_ltv = bytes([len(program_info_bytes) + 1, 0x03]) + program_info_bytes
pbp_service_data = struct.pack('<H', 0x1856) + bytes([pbp_features, len(pbp_metadata_ltv)]) + pbp_metadata_ltv
advertising_data_types.append(
(core.AdvertisingData.SERVICE_DATA_16_BIT_UUID, pbp_service_data)
)
advertising_set = await device.create_advertising_set(
random_address=hci.Address(conf.random_address),
advertising_parameters=bumble.device.AdvertisingParameters(
@@ -355,11 +394,7 @@ async def init_broadcast(
),
advertising_data=(
bigs[f'big{i}']['broadcast_audio_announcement'].get_advertising_data()
+ bytes(
core.AdvertisingData(
[(core.AdvertisingData.BROADCAST_NAME, conf.name.encode())]
)
)
+ bytes(core.AdvertisingData(advertising_data_types))
+ advertising_manufacturer_data
),
periodic_advertising_parameters=bumble.device.PeriodicAdvertisingParameters(
@@ -384,7 +419,7 @@ async def init_broadcast(
big = await device.create_big(
bigs[f'big{i}']['advertising_set'],
parameters=bumble.device.BigParameters(
num_bis=1,
num_bis=conf.num_bis,
sdu_interval=global_config.qos_config.iso_int_multiple_10ms*10000, # Is the same as iso interval
max_sdu=global_config.octets_per_frame,
max_transport_latency=global_config.qos_config.max_transport_latency_ms,
@@ -402,11 +437,18 @@ async def init_broadcast(
direction=bis_link.Direction.HOST_TO_CONTROLLER
)
iso_queue = bumble.device.IsoPacketStream(big.bis_links[0], conf.iso_que_len)
# Create ISO queue(s) - one per BIS
iso_queues = [
bumble.device.IsoPacketStream(link, conf.iso_que_len)
for link in big.bis_links
]
logging.info('Setup ISO Data Path')
bigs[f'big{i}']['iso_queue'] = iso_queue
bigs[f'big{i}']['iso_queues'] = iso_queues
bigs[f'big{i}']['num_bis'] = conf.num_bis
# Keep backward compat: iso_queue points to first queue
bigs[f'big{i}']['iso_queue'] = iso_queues[0]
if global_config.debug:
logging.info(f'big{i} parameters are:')
@@ -638,8 +680,16 @@ class Streamer():
pcm_format = await audio_input.open()
if pcm_format.channels != 1:
logging.info("Input device provides %d channels will down-mix to mono for LC3", pcm_format.channels)
num_bis = big.get('num_bis', 1)
if num_bis == 2 and pcm_format.channels < 2:
logging.error("Stereo (num_bis=2) requires at least 2 input channels, got %d", pcm_format.channels)
return
if pcm_format.channels != num_bis:
if num_bis == 1:
logging.info("Input device provides %d channels will down-mix to mono for LC3", pcm_format.channels)
else:
logging.info("Input device provides %d channels using first %d for stereo", pcm_format.channels, num_bis)
if pcm_format.sample_type == audio_io.PcmFormat.SampleType.INT16:
pcm_bit_depth = 16
elif pcm_format.sample_type == audio_io.PcmFormat.SampleType.FLOAT32:
@@ -648,20 +698,26 @@ class Streamer():
logging.error("Only INT16 and FLOAT32 sample types are supported")
return
encoder = lc3.Encoder(
frame_duration_us=global_config.frame_duration_us,
sample_rate_hz=global_config.auracast_sampling_rate_hz,
num_channels=1,
input_sample_rate_hz=pcm_format.sample_rate,
)
# Create one encoder per BIS (mono: 1 encoder, stereo: 2 encoders)
encoders = [
lc3.Encoder(
frame_duration_us=global_config.frame_duration_us,
sample_rate_hz=global_config.auracast_sampling_rate_hz,
num_channels=1,
input_sample_rate_hz=pcm_format.sample_rate,
)
for _ in range(num_bis)
]
lc3_frame_samples = encoder.get_frame_samples() # number of the pcm samples per lc3 frame
lc3_frame_samples = encoders[0].get_frame_samples() # number of the pcm samples per lc3 frame
big['pcm_bit_depth'] = pcm_bit_depth
big['channels'] = pcm_format.channels
big['lc3_frame_samples'] = lc3_frame_samples
big['lc3_bytes_per_frame'] = global_config.octets_per_frame
big['audio_input'] = audio_input
big['encoder'] = encoder
big['encoders'] = encoders
# Keep backward compat
big['encoder'] = encoders[0]
big['precoded'] = False
logging.info("Streaming audio...")
@@ -686,8 +742,8 @@ class Streamer():
# Ensure frames generator exists (so we can aclose() on stop)
frames_gen = big.get('frames_gen')
if frames_gen is None:
# For stereo, request frame_samples per channel (interleaved input)
frames_gen = big['audio_input'].frames(big['lc3_frame_samples'])
big['frames_gen'] = frames_gen
# Initialize perf tracking bucket per BIG
@@ -713,14 +769,31 @@ class Streamer():
# Measure LC3 encoding time
t1 = time.perf_counter()
lc3_frame = big['encoder'].encode(
pcm_frame, num_bytes=big['lc3_bytes_per_frame'], bit_depth=big['pcm_bit_depth']
)
num_bis = big.get('num_bis', 1)
if num_bis == 1:
# Mono: single encoder, single queue
lc3_frame = big['encoder'].encode(
pcm_frame, num_bytes=big['lc3_bytes_per_frame'], bit_depth=big['pcm_bit_depth']
)
lc3_frames_out = [lc3_frame]
else:
# Stereo: split interleaved PCM into L/R, encode separately
pcm_array = np.frombuffer(pcm_frame, dtype=np.int16)
channels_in = big['channels']
lc3_frames_out = []
for ch_idx, encoder in enumerate(big['encoders']):
# Extract channel (interleaved: L,R,L,R,... or L,R,C,... for >2 ch)
ch_pcm = pcm_array[ch_idx::channels_in].tobytes()
lc3_frame = encoder.encode(
ch_pcm, num_bytes=big['lc3_bytes_per_frame'], bit_depth=big['pcm_bit_depth']
)
lc3_frames_out.append(lc3_frame)
dt_enc = time.perf_counter() - t1
# Measure write blocking time
t2 = time.perf_counter()
await big['iso_queue'].write(lc3_frame)
for q_idx, lc3_frame in enumerate(lc3_frames_out):
await big['iso_queues'][q_idx].write(lc3_frame)
dt_write = time.perf_counter() - t2
# Total loop duration
@@ -845,7 +918,7 @@ if __name__ == "__main__":
)
# TODO: How can we use other iso interval than 10ms ?(medium or low rel) ? - nrf53audio receiver repports I2S tx underrun
config.qos_config=auracast_config.AuracastQosHigh()
config.qos_config=auracast_config.AuracastQosDefault()
#config.transport='serial:/dev/serial/by-id/usb-ZEPHYR_Zephyr_HCI_UART_sample_81BD14B8D71B5662-if00,1000000,rtscts' # transport for nrf52 dongle
#config.transport='serial:/dev/serial/by-id/usb-SEGGER_J-Link_001050076061-if02,1000000,rtscts' # transport for nrf53dk

View File

@@ -140,7 +140,7 @@ async def main():
os.chdir(os.path.dirname(__file__))
global_conf = auracast_config.AuracastGlobalConfig(
qos_config=auracast_config.AuracastQosHigh()
qos_config=auracast_config.AuracastQosDefault()
)
#global_conf.transport='serial:/dev/serial/by-id/usb-SEGGER_J-Link_001057705357-if02,1000000,rtscts' # transport for nrf54l15dk
global_conf.transport='serial:/dev/serial/by-id/usb-ZEPHYR_Zephyr_HCI_UART_sample_81BD14B8D71B5662-if00,115200,rtscts' #nrf52dongle hci_uart usb cdc

View File

@@ -159,7 +159,7 @@ if __name__ == "__main__":
],
immediate_rendering=False,
presentation_delay_us=40000,
qos_config=auracast_config.AuracastQosHigh(),
qos_config=auracast_config.AuracastQosDefault(),
auracast_sampling_rate_hz = LC3_SRATE,
octets_per_frame = OCTETS_PER_FRAME,
transport=TRANSPORT1,

View File

@@ -2,7 +2,7 @@ import os
import asyncio
import logging as log
async def reset_nrf54l(slot: int = 0, timeout: float = 8.0):
async def reset_nrf54l(interface: int = 0, timeout: float = 8.0):
"""
Reset the nRF54L target using OpenOCD before starting broadcast.
@@ -24,7 +24,7 @@ async def reset_nrf54l(slot: int = 0, timeout: float = 8.0):
try:
# Resolve project directory and filenames
proj_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..', 'openocd'))
names = ['raspberrypi-swd0.cfg', 'swd0.cfg'] if slot == 0 else ['raspberrypi-swd1.cfg', 'swd1.cfg']
names = ['raspberrypi-swd0.cfg', 'swd0.cfg'] if interface == 0 else ['raspberrypi-swd1.cfg', 'swd1.cfg']
cfg = None
for n in names:
p = os.path.join(proj_dir, n)
@@ -56,7 +56,7 @@ async def reset_nrf54l(slot: int = 0, timeout: float = 8.0):
ok = await _run(cmd)
if ok:
log.info("reset_nrf54l: reset succeeded (slot %d) using %s", slot, cfg)
log.info("reset_nrf54l: reset succeeded (interface %d) using %s", interface, cfg)
except FileNotFoundError:
log.error("reset_nrf54l: openocd not found; skipping reset")
@@ -71,7 +71,10 @@ if __name__ == '__main__':
format='%(asctime)s.%(msecs)03d %(levelname)s: %(message)s',
datefmt='%Y-%m-%d %H:%M:%S'
)
interface_to_reset = 0
log.info(f"Executing reset for interface {interface_to_reset}")
asyncio.run(reset_nrf54l(interface=interface_to_reset))
slot_to_reset = 1
log.info(f"Executing reset for slot {slot_to_reset}")
asyncio.run(reset_nrf54l(slot=slot_to_reset))
interface_to_reset = 1
log.info(f"Executing reset for interface {interface_to_reset}")
asyncio.run(reset_nrf54l(interface=interface_to_reset))