Merge pull request #849 from google/gbg/auracast-multi-broadcast

This commit is contained in:
Gilles Boccon-Gibod
2026-01-02 09:02:15 -08:00
committed by GitHub
10 changed files with 868 additions and 512 deletions

View File

@@ -23,10 +23,14 @@ import contextlib
import dataclasses
import functools
import logging
from collections.abc import AsyncGenerator, Coroutine
from typing import Any
import secrets
from collections.abc import AsyncGenerator, Awaitable, Callable, Iterable, Sequence
from typing import (
Any,
)
import click
import tomli
try:
import lc3 # type: ignore # pylint: disable=E0401
@@ -58,8 +62,11 @@ AURACAST_DEFAULT_DEVICE_ADDRESS = hci.Address('F0:F1:F2:F3:F4:F5')
AURACAST_DEFAULT_SYNC_TIMEOUT = 5.0
AURACAST_DEFAULT_ATT_MTU = 256
AURACAST_DEFAULT_FRAME_DURATION = 10000
AURACAST_DEFAULT_SAMPLE_RATE = 48000
AURACAST_DEFAULT_TRANSMIT_BITRATE = 80000
AURACAST_DEFAULT_BROADCAST_ID = 123456
AURACAST_DEFAULT_BROADCAST_NAME = 'Bumble Auracast'
AURACAST_DEFAULT_LANGUAGE = 'en'
AURACAST_DEFAULT_PROGRAM_INFO = 'Disco'
# -----------------------------------------------------------------------------
@@ -103,6 +110,95 @@ def broadcast_code_bytes(broadcast_code: str) -> bytes:
return broadcast_code_utf8 + padding
def parse_broadcast_list(filename: str) -> Sequence[Broadcast]:
broadcasts: list[Broadcast] = []
with open(filename, "rb") as config_file:
config = tomli.load(config_file)
for broadcast in config.get("broadcasts", []):
sources = []
for source in broadcast.get("sources", []):
sources.append(
BroadcastSource(
input=source["input"],
input_format=source.get("format", "auto"),
bitrate=source.get(
"bitrate", AURACAST_DEFAULT_TRANSMIT_BITRATE
),
)
)
manufacturer_data = broadcast.get("manufacturer_data")
if manufacturer_data is not None:
manufacturer_data = (
manufacturer_data.get("company_id"),
bytes.fromhex(manufacturer_data["data"]),
)
broadcasts.append(
Broadcast(
sources=sources,
public=broadcast.get("public", True),
broadcast_id=broadcast.get("id", AURACAST_DEFAULT_BROADCAST_ID),
broadcast_name=broadcast["name"],
broadcast_code=broadcast.get("code"),
manufacturer_data=broadcast.get("manufacturer_data"),
language=broadcast.get("language"),
program_info=broadcast.get("program_info"),
)
)
return broadcasts
def assign_broadcast_ids(broadcasts: Sequence[Broadcast]) -> None:
broadcast_ids = set()
for broadcast in broadcasts:
if broadcast.broadcast_id:
if broadcast.broadcast_id in broadcast_ids:
raise ValueError(f'duplicate broadcast ID {broadcast.broadcast_id}')
broadcast_ids.add(broadcast.broadcast_id)
else:
while True:
broadcast.broadcast_id = 1 + secrets.randbelow(0xFFFFFF)
if broadcast.broadcast_id not in broadcast_ids:
broadcast_ids.add(broadcast.broadcast_id)
break
@dataclasses.dataclass
class Broadcast:
sources: list[BroadcastSource]
public: bool
broadcast_id: int # 0 means unassigned
broadcast_name: str
broadcast_code: str | None
manufacturer_data: tuple[int, bytes] | None = None
language: str | None = None
program_info: str | None = None
audio_sources: list[AudioSource] = dataclasses.field(default_factory=list)
iso_queues: list[bumble.device.IsoPacketStream] = dataclasses.field(
default_factory=list
)
@dataclasses.dataclass
class BroadcastSource:
input: str
input_format: str
bitrate: int
@dataclasses.dataclass
class AudioSource:
audio_input: audio_io.AudioInput
pcm_format: audio_io.PcmFormat
pcm_bit_depth: int | None
lc3_encoder: lc3.Encoder
lc3_frame_samples: int
lc3_frame_size: int
audio_frames: AsyncGenerator
# -----------------------------------------------------------------------------
# Scan For Broadcasts
# -----------------------------------------------------------------------------
@@ -119,6 +215,7 @@ class BroadcastScanner(bumble.utils.EventEmitter):
appearance: core.Appearance | None = None
biginfo: bumble.device.BigInfoAdvertisement | None = None
manufacturer_data: tuple[str, bytes] | None = None
device_name: str | None = None
def __post_init__(self) -> None:
super().__init__()
@@ -146,6 +243,10 @@ class BroadcastScanner(bumble.utils.EventEmitter):
)
continue
self.device_name = advertisement.data.get(
core.AdvertisingData.Type.COMPLETE_LOCAL_NAME
)
self.appearance = advertisement.data.get(
core.AdvertisingData.Type.APPEARANCE
)
@@ -170,7 +271,9 @@ class BroadcastScanner(bumble.utils.EventEmitter):
color(self.sync.state.name, 'green'),
)
if self.name is not None:
print(f' {color("Name", "cyan")}: {self.name}')
print(f' {color("Broadcast Name", "cyan")}: {self.name}')
if self.device_name:
print(f' {color("Device Name", "cyan")}: {self.device_name}')
if self.appearance:
print(f' {color("Appearance", "cyan")}: {str(self.appearance)}')
print(f' {color("RSSI", "cyan")}: {self.rssi}')
@@ -193,8 +296,13 @@ class BroadcastScanner(bumble.utils.EventEmitter):
f' {color("Features", "cyan")}: '
f'{self.public_broadcast_announcement.features.name}'
)
print(f' {color("Metadata", "cyan")}:')
print(self.public_broadcast_announcement.metadata.pretty_print(' '))
if self.public_broadcast_announcement.metadata.entries:
print(f' {color("Metadata", "cyan")}: ')
print(
self.public_broadcast_announcement.metadata.pretty_print(
' '
)
)
if self.basic_audio_announcement:
print(color(' Audio:', 'cyan'))
@@ -210,6 +318,7 @@ class BroadcastScanner(bumble.utils.EventEmitter):
color(' Coding Format: ', 'green'),
subgroup.codec_id.codec_id.name,
)
if subgroup.codec_id.company_id:
print(
color(' Company ID: ', 'green'),
subgroup.codec_id.company_id,
@@ -224,6 +333,7 @@ class BroadcastScanner(bumble.utils.EventEmitter):
subgroup.codec_specific_configuration, ' '
),
)
if subgroup.metadata.entries:
print(color(' Metadata: ', 'yellow'))
print(subgroup.metadata.pretty_print(' '))
@@ -292,7 +402,7 @@ class BroadcastScanner(bumble.utils.EventEmitter):
self.device = device
self.filter_duplicates = filter_duplicates
self.sync_timeout = sync_timeout
self.broadcasts = dict[hci.Address, BroadcastScanner.Broadcast]()
self.broadcasts = dict[tuple[hci.Address, int], BroadcastScanner.Broadcast]()
device.on('advertisement', self.on_advertisement)
async def start(self) -> None:
@@ -310,7 +420,7 @@ class BroadcastScanner(bumble.utils.EventEmitter):
core.AdvertisingData.Type.SERVICE_DATA_16_BIT_UUID
)
) or not (
broadcast_audio_announcement := next(
broadcast_audio_announcement_ad := next(
(
ad
for ad in ads
@@ -325,7 +435,13 @@ class BroadcastScanner(bumble.utils.EventEmitter):
core.AdvertisingData.Type.BROADCAST_NAME
)
if broadcast := self.broadcasts.get(advertisement.address):
broadcast_audio_announcement = bap.BroadcastAudioAnnouncement.from_bytes(
broadcast_audio_announcement_ad[1]
)
if broadcast := self.broadcasts.get(
(advertisement.address, broadcast_audio_announcement.broadcast_id)
):
broadcast.update(advertisement)
return
@@ -333,9 +449,7 @@ class BroadcastScanner(bumble.utils.EventEmitter):
self.on_new_broadcast(
broadcast_name[0] if broadcast_name else None,
advertisement,
bap.BroadcastAudioAnnouncement.from_bytes(
broadcast_audio_announcement[1]
).broadcast_id,
broadcast_audio_announcement.broadcast_id,
)
)
@@ -353,12 +467,12 @@ class BroadcastScanner(bumble.utils.EventEmitter):
)
broadcast = self.Broadcast(name, periodic_advertising_sync, broadcast_id)
broadcast.update(advertisement)
self.broadcasts[advertisement.address] = broadcast
self.broadcasts[(advertisement.address, broadcast_id)] = broadcast
periodic_advertising_sync.on('loss', lambda: self.on_broadcast_loss(broadcast))
self.emit('new_broadcast', broadcast)
def on_broadcast_loss(self, broadcast: Broadcast) -> None:
del self.broadcasts[broadcast.sync.advertiser_address]
def on_broadcast_loss(self, broadcast: BroadcastScanner.Broadcast) -> None:
del self.broadcasts[(broadcast.sync.advertiser_address, broadcast.broadcast_id)]
bumble.utils.AsyncRunner.spawn(broadcast.sync.terminate())
self.emit('broadcast_loss', broadcast)
@@ -462,9 +576,8 @@ async def find_broadcast_by_name(
async def run_scan(
filter_duplicates: bool, sync_timeout: float, transport: str
device: bumble.device.Device, filter_duplicates: bool, sync_timeout: float
) -> None:
async with create_device(transport) as device:
if not device.supports_le_periodic_advertising:
print(color('Periodic advertising not supported', 'red'))
return
@@ -475,13 +588,12 @@ async def run_scan(
async def run_assist(
device: bumble.device.Device,
broadcast_name: str | None,
source_id: int | None,
command: str,
transport: str,
address: str,
) -> None:
async with create_device(transport) as device:
if not device.supports_le_periodic_advertising:
print(color('Periodic advertising not supported', 'red'))
return
@@ -513,15 +625,14 @@ async def run_assist(
# Subscribe to and read the broadcast receive state characteristics
def on_broadcast_receive_state_update(
value: bass.BroadcastReceiveState, index: int
value: bass.BroadcastReceiveState | None, index: int
) -> None:
if value is not None:
print(
f"{color(f'Broadcast Receive State Update [{index}]:', 'green')} {value}"
)
for i, broadcast_receive_state in enumerate(
bass_client.broadcast_receive_states
):
for i, broadcast_receive_state in enumerate(bass_client.broadcast_receive_states):
try:
await broadcast_receive_state.subscribe(
functools.partial(on_broadcast_receive_state_update, index=i)
@@ -535,9 +646,7 @@ async def run_assist(
error,
)
value = await broadcast_receive_state.read_value()
print(
f'{color(f"Initial Broadcast Receive State [{i}]:", "green")} {value}'
)
print(f'{color(f"Initial Broadcast Receive State [{i}]:", "green")} {value}')
if command == 'monitor-state':
await peer.sustain()
@@ -645,8 +754,7 @@ async def run_assist(
print(color(f'!!! invalid command {command}'))
async def run_pair(transport: str, address: str) -> None:
async with create_device(transport) as device:
async def run_pair(device: bumble.device.Device, address: str) -> None:
# Connect to the server
print(f'=== Connecting to {address}...')
async with device.connect_as_gatt(address) as peer:
@@ -658,7 +766,7 @@ async def run_pair(transport: str, address: str) -> None:
async def run_receive(
transport: str,
device: bumble.device.Device,
broadcast_id: int | None,
output: str,
broadcast_code: str | None,
@@ -673,7 +781,6 @@ async def run_receive(
print(error)
return
async with create_device(transport) as device:
if not device.supports_le_periodic_advertising:
print(color('Periodic advertising not supported', 'red'))
return
@@ -741,9 +848,10 @@ async def run_receive(
]
packet_stats = [0, 0]
async with contextlib.AsyncExitStack() as stack:
audio_output = await audio_io.create_audio_output(output)
stack.push_async_callback(audio_output.aclose)
# This try should be replaced with contextlib.aclosing() when python 3.9 is no
# longer needed.
try:
await audio_output.open(
audio_io.PcmFormat(
audio_io.PcmFormat.Endianness.LITTLE,
@@ -791,52 +899,167 @@ async def run_receive(
terminated = asyncio.Event()
big_sync.on(big_sync.Event.TERMINATION, lambda _: terminated.set())
await terminated.wait()
finally:
await audio_output.aclose()
async def run_transmit(
transport: str,
broadcast_id: int,
broadcast_code: str | None,
broadcast_name: str,
bitrate: int,
manufacturer_data: tuple[int, bytes] | None,
input: str,
input_format: str,
device: bumble.device.Device, broadcasts: Iterable[Broadcast]
) -> None:
# Run a pre-flight check for the input.
# Run a pre-flight check for the input(s).
try:
if not audio_io.check_audio_input(input):
for broadcast in broadcasts:
for source in broadcast.sources:
if not audio_io.check_audio_input(source.input):
return
except ValueError as error:
print(error)
return
async with create_device(transport) as device:
if not device.supports_le_periodic_advertising:
print(color('Periodic advertising not supported', 'red'))
return
def on_flow():
pending = []
queued = []
completed = []
for broadcast in broadcasts:
for iso_queue in broadcast.iso_queues:
data_packet_queue = iso_queue.data_packet_queue
pending.append(str(data_packet_queue.pending))
queued.append(str(data_packet_queue.queued))
completed.append(str(data_packet_queue.completed))
print(
f'\rPACKETS: '
f'pending={",".join(pending)} | '
f'queued={",".join(queued)} | '
f'completed={",".join(completed)}',
end='',
)
audio_inputs: list[audio_io.AudioInput] = []
try:
# Setup audio sources
for broadcast_index, broadcast in enumerate(broadcasts):
channel_count = 0
max_lc3_frame_size = 0
max_sample_rate = 0
for source in broadcast.sources:
print(f'Setting up audio input: {source.input}')
# Open the audio input
audio_input = await audio_io.create_audio_input(
source.input, source.input_format
)
pcm_format = await audio_input.open()
audio_inputs.append(audio_input)
# Check that the number of channels is supported
if pcm_format.channels not in (1, 2):
print("Only 1 and 2 channels PCM configurations are supported")
return
channel_count += pcm_format.channels
# Check that the sample type is supported
if pcm_format.sample_type == audio_io.PcmFormat.SampleType.INT16:
pcm_bit_depth = 16
elif pcm_format.sample_type == audio_io.PcmFormat.SampleType.FLOAT32:
pcm_bit_depth = None
else:
print("Only INT16 and FLOAT32 sample types are supported")
return
# Check that the sample rate is supported
if pcm_format.sample_rate not in (16000, 24000, 48000):
print(f'Sample rate {pcm_format.sample_rate} not supported')
return
max_sample_rate = max(max_sample_rate, pcm_format.sample_rate)
# Compute LC3 parameters and create and encoder
encoder = lc3.Encoder(
frame_duration_us=AURACAST_DEFAULT_FRAME_DURATION,
sample_rate_hz=pcm_format.sample_rate,
num_channels=pcm_format.channels,
input_sample_rate_hz=pcm_format.sample_rate,
)
lc3_frame_samples = encoder.get_frame_samples()
lc3_frame_size = encoder.get_frame_bytes(source.bitrate)
max_lc3_frame_size = max(max_lc3_frame_size, lc3_frame_size)
print(
f'Encoding {source.input} with {lc3_frame_samples} '
f'PCM samples per {lc3_frame_size} byte frame'
)
broadcast.audio_sources.append(
AudioSource(
audio_input=audio_input,
pcm_format=pcm_format,
pcm_bit_depth=pcm_bit_depth,
lc3_encoder=encoder,
lc3_frame_samples=lc3_frame_samples,
lc3_frame_size=lc3_frame_size,
audio_frames=audio_input.frames(lc3_frame_samples),
)
)
# Setup advertising and BIGs
metadata = le_audio.Metadata()
if broadcast.language is not None:
metadata.entries.append(
le_audio.Metadata.Entry(
tag=le_audio.Metadata.Tag.LANGUAGE,
data=broadcast.language.encode('utf-8'),
)
)
if broadcast.program_info is not None:
metadata.entries.append(
le_audio.Metadata.Entry(
tag=le_audio.Metadata.Tag.PROGRAM_INFO,
data=broadcast.program_info.encode('utf-8'),
)
)
if broadcast.public:
# Infer features from sources
features = pbp.PublicBroadcastAnnouncement.Features(0)
if broadcast.broadcast_code is not None:
features |= pbp.PublicBroadcastAnnouncement.Features.ENCRYPTED
for audio_source in broadcast.audio_sources:
if audio_source.pcm_format.sample_rate == 48000:
features |= (
pbp.PublicBroadcastAnnouncement.Features.HIGH_QUALITY_CONFIGURATION
)
else:
features |= (
pbp.PublicBroadcastAnnouncement.Features.STANDARD_QUALITY_CONFIGURATION
)
public_broadcast_announcement = pbp.PublicBroadcastAnnouncement(
features=features, metadata=metadata
)
else:
public_broadcast_announcement = None
broadcast_audio_announcement = bap.BroadcastAudioAnnouncement(
broadcast.broadcast_id
)
basic_audio_announcement = bap.BasicAudioAnnouncement(
presentation_delay=40000,
subgroups=[
bap.BasicAudioAnnouncement.Subgroup(
codec_id=hci.CodingFormat(codec_id=hci.CodecID.LC3),
codec_specific_configuration=bap.CodecSpecificConfiguration(
sampling_frequency=bap.SamplingFrequency.FREQ_48000,
sampling_frequency=bap.SamplingFrequency.from_hz(
audio_source.pcm_format.sample_rate
),
frame_duration=bap.FrameDuration.DURATION_10000_US,
octets_per_codec_frame=100,
octets_per_codec_frame=audio_source.lc3_frame_size,
),
metadata=le_audio.Metadata(
metadata=le_audio.Metadata(),
bis=(
[
le_audio.Metadata.Entry(
tag=le_audio.Metadata.Tag.LANGUAGE, data=b'eng'
),
le_audio.Metadata.Entry(
tag=le_audio.Metadata.Tag.PROGRAM_INFO, data=b'Disco'
),
]
),
bis=[
bap.BasicAudioAnnouncement.BIS(
index=1,
codec_specific_configuration=bap.CodecSpecificConfiguration(
@@ -849,124 +1072,134 @@ async def run_transmit(
audio_channel_allocation=bap.AudioLocation.FRONT_RIGHT
),
),
]
if audio_source.pcm_format.channels == 2
else [
bap.BasicAudioAnnouncement.BIS(
index=1,
codec_specific_configuration=bap.CodecSpecificConfiguration(
audio_channel_allocation=bap.AudioLocation.FRONT_LEFT
),
),
]
),
)
for audio_source in broadcast.audio_sources
],
)
],
)
broadcast_audio_announcement = bap.BroadcastAudioAnnouncement(broadcast_id)
advertising_data_types: list[core.DataType] = [
data_types.BroadcastName(broadcast_name)
data_types.CompleteLocalName(AURACAST_DEFAULT_DEVICE_NAME),
data_types.Appearance(
core.Appearance.Category.AUDIO_SOURCE,
core.Appearance.AudioSourceSubcategory.BROADCASTING_DEVICE,
),
data_types.BroadcastName(broadcast.broadcast_name),
]
if manufacturer_data is not None:
if broadcast.manufacturer_data is not None:
advertising_data_types.append(
data_types.ManufacturerSpecificData(*manufacturer_data)
data_types.ManufacturerSpecificData(*broadcast.manufacturer_data)
)
advertising_data = bytes(core.AdvertisingData(advertising_data_types))
if public_broadcast_announcement:
advertising_data += bytes(
public_broadcast_announcement.get_advertising_data()
)
if broadcast_audio_announcement:
advertising_data += broadcast_audio_announcement.get_advertising_data()
print('Starting Periodic Advertising:')
print(f" Extended Advertising data size: {len(advertising_data)}")
print(
f" Periodic Advertising data size: {len(basic_audio_announcement.get_advertising_data())}"
)
advertising_set = await device.create_advertising_set(
advertising_parameters=bumble.device.AdvertisingParameters(
advertising_event_properties=bumble.device.AdvertisingEventProperties(
is_connectable=False
),
primary_advertising_interval_min=100,
primary_advertising_interval_max=200,
),
advertising_data=(
broadcast_audio_announcement.get_advertising_data()
+ bytes(core.AdvertisingData(advertising_data_types))
primary_advertising_interval_max=1000,
advertising_sid=broadcast_index,
),
advertising_data=advertising_data,
periodic_advertising_parameters=bumble.device.PeriodicAdvertisingParameters(
periodic_advertising_interval_min=80,
periodic_advertising_interval_max=160,
periodic_advertising_interval_min=100,
periodic_advertising_interval_max=1000,
),
periodic_advertising_data=basic_audio_announcement.get_advertising_data(),
auto_restart=True,
auto_start=True,
)
print('Start Periodic Advertising')
await advertising_set.start_periodic()
async with contextlib.AsyncExitStack() as stack:
audio_input = await audio_io.create_audio_input(input, input_format)
pcm_format = await audio_input.open()
stack.push_async_callback(audio_input.aclose)
if pcm_format.channels != 2:
print("Only 2 channels PCM configurations are supported")
return
if pcm_format.sample_type == audio_io.PcmFormat.SampleType.INT16:
pcm_bit_depth = 16
elif pcm_format.sample_type == audio_io.PcmFormat.SampleType.FLOAT32:
pcm_bit_depth = None
else:
print("Only INT16 and FLOAT32 sample types are supported")
return
encoder = lc3.Encoder(
frame_duration_us=AURACAST_DEFAULT_FRAME_DURATION,
sample_rate_hz=AURACAST_DEFAULT_SAMPLE_RATE,
num_channels=pcm_format.channels,
input_sample_rate_hz=pcm_format.sample_rate,
)
lc3_frame_samples = encoder.get_frame_samples()
lc3_frame_size = encoder.get_frame_bytes(bitrate)
print(
f'Encoding with {lc3_frame_samples} '
f'PCM samples per {lc3_frame_size} byte frame'
)
print('Setup BIG')
print('Setting up BIG')
big = await device.create_big(
advertising_set,
parameters=bumble.device.BigParameters(
num_bis=pcm_format.channels,
num_bis=channel_count,
sdu_interval=AURACAST_DEFAULT_FRAME_DURATION,
max_sdu=lc3_frame_size,
max_sdu=max_lc3_frame_size,
max_transport_latency=65,
rtn=4,
broadcast_code=(
broadcast_code_bytes(broadcast_code) if broadcast_code else None
broadcast_code_bytes(broadcast.broadcast_code)
if broadcast.broadcast_code
else None
),
),
)
for bis_link in big.bis_links:
for i, bis_link in enumerate(big.bis_links):
print(f'Setup ISO for BIS {bis_link.handle}')
await bis_link.setup_data_path(
direction=bis_link.Direction.HOST_TO_CONTROLLER
)
iso_queue = bumble.device.IsoPacketStream(bis_link, 64)
iso_queue.data_packet_queue.on('flow', on_flow)
broadcast.iso_queues.append(iso_queue)
iso_queues = [
bumble.device.IsoPacketStream(bis_link, 64)
for bis_link in big.bis_links
print('Transmitting audio from source(s)')
while True:
for broadcast in broadcasts:
iso_queue_index = 0
for audio_source in broadcast.audio_sources:
pcm_frame = await anext(audio_source.audio_frames)
lc3_frame = audio_source.lc3_encoder.encode(
pcm_frame,
num_bytes=audio_source.pcm_format.channels
* audio_source.lc3_frame_size,
bit_depth=audio_source.pcm_bit_depth,
)
for lc3_chunk_start in range(
0, len(lc3_frame), audio_source.lc3_frame_size
):
await broadcast.iso_queues[iso_queue_index].write(
lc3_frame[
lc3_chunk_start : lc3_chunk_start
+ audio_source.lc3_frame_size
]
def on_flow():
data_packet_queue = iso_queues[0].data_packet_queue
print(
f'\rPACKETS: pending={data_packet_queue.pending}, '
f'queued={data_packet_queue.queued}, '
f'completed={data_packet_queue.completed}',
end='',
)
iso_queues[0].data_packet_queue.on('flow', on_flow)
frame_count = 0
async for pcm_frame in audio_input.frames(lc3_frame_samples):
lc3_frame = encoder.encode(
pcm_frame, num_bytes=2 * lc3_frame_size, bit_depth=pcm_bit_depth
)
mid = len(lc3_frame) // 2
await iso_queues[0].write(lc3_frame[:mid])
await iso_queues[1].write(lc3_frame[mid:])
frame_count += 1
iso_queue_index += 1
finally:
for audio_input in audio_inputs:
await audio_input.aclose()
def run_async(async_command: Coroutine) -> None:
def run_async(
async_command: Callable[..., Awaitable[Any]],
transport: str,
*args,
) -> None:
async def run_with_transport():
async with create_device(transport) as device:
await async_command(device, *args)
try:
asyncio.run(async_command)
asyncio.run(run_with_transport())
except core.ProtocolError as error:
if error.error_namespace == 'att' and error.error_code in list(
bass.ApplicationError
@@ -1004,7 +1237,7 @@ def auracast(ctx):
@click.pass_context
def scan(ctx, filter_duplicates, sync_timeout, transport):
"""Scan for public broadcasts"""
run_async(run_scan(filter_duplicates, sync_timeout, transport))
run_async(run_scan, transport, filter_duplicates, sync_timeout)
@auracast.command('assist')
@@ -1031,7 +1264,7 @@ def scan(ctx, filter_duplicates, sync_timeout, transport):
@click.pass_context
def assist(ctx, broadcast_name, source_id, command, transport, address):
"""Scan for broadcasts on behalf of an audio server"""
run_async(run_assist(broadcast_name, source_id, command, transport, address))
run_async(run_assist, transport, broadcast_name, source_id, command, address)
@auracast.command('pair')
@@ -1040,7 +1273,7 @@ def assist(ctx, broadcast_name, source_id, command, transport, address):
@click.pass_context
def pair(ctx, transport, address):
"""Pair with an audio server"""
run_async(run_pair(transport, address))
run_async(run_pair, transport, address)
@auracast.command('receive')
@@ -1095,7 +1328,7 @@ def receive(
):
"""Receive a broadcast source"""
run_async(
run_receive(
run_receive,
transport,
broadcast_id,
output,
@@ -1103,14 +1336,21 @@ def receive(
sync_timeout,
subgroup,
)
)
@auracast.command('transmit')
@click.argument('transport')
@click.option(
'--broadcast-list',
metavar='BROADCAST_LIST',
help=(
'Filename of a TOML broadcast list with specification(s) for one or more '
'broadcast sources. When used, single-source options, including --input, '
'--input-format and others, are ignored.'
),
)
@click.option(
'--input',
required=True,
help=(
"Audio input. "
"'device' -> use the host's default sound input device, "
@@ -1139,18 +1379,18 @@ def receive(
'--broadcast-id',
metavar='BROADCAST_ID',
type=int,
default=123456,
default=AURACAST_DEFAULT_BROADCAST_ID,
help='Broadcast ID',
)
@click.option(
'--broadcast-code',
metavar='BROADCAST_CODE',
help='Broadcast encryption code in hex format',
help='Broadcast encryption code in hex format or as a string',
)
@click.option(
'--broadcast-name',
metavar='BROADCAST_NAME',
default='Bumble Auracast',
default=AURACAST_DEFAULT_BROADCAST_NAME,
help='Broadcast name',
)
@click.option(
@@ -1168,15 +1408,43 @@ def receive(
def transmit(
ctx,
transport,
broadcast_id,
broadcast_code,
manufacturer_data,
broadcast_name,
bitrate,
broadcast_list,
input,
input_format,
broadcast_id,
broadcast_code,
broadcast_name,
bitrate,
manufacturer_data,
):
"""Transmit a broadcast source"""
if broadcast_list:
broadcasts = parse_broadcast_list(broadcast_list)
if not broadcasts:
print(color('!!! Broadcast list is empty or invalid', 'red'))
return
for broadcast in broadcasts:
if not broadcast.sources:
print(
color(
f'!!! Broadcast "{broadcast.broadcast_name}" has no sources',
'red',
)
)
return
else:
if input is None and broadcast_list is None:
print(
color('!!! --input is required if --broadcast-list is not used', 'red')
)
return
if (
input == 'device' or input.startswith('device:')
) and input_format == 'auto':
# Use a default format for device inputs
input_format = 'int16le,48000,1'
if manufacturer_data:
vendor_id_str, data_hex = manufacturer_data.split(':')
vendor_id = int(vendor_id_str)
@@ -1185,22 +1453,28 @@ def transmit(
else:
manufacturer_data_tuple = None
if (input == 'device' or input.startswith('device:')) and input_format == 'auto':
# Use a default format for device inputs
input_format = 'int16le,48000,1'
run_async(
run_transmit(
transport=transport,
broadcast_id=broadcast_id,
broadcast_code=broadcast_code,
broadcast_name=broadcast_name,
bitrate=bitrate,
manufacturer_data=manufacturer_data_tuple,
broadcasts = [
Broadcast(
sources=[
BroadcastSource(
input=input,
input_format=input_format,
bitrate=bitrate,
)
],
public=True,
broadcast_id=broadcast_id,
broadcast_name=broadcast_name,
broadcast_code=broadcast_code,
manufacturer_data=manufacturer_data_tuple,
language=AURACAST_DEFAULT_LANGUAGE,
program_info=AURACAST_DEFAULT_PROGRAM_INFO,
)
]
assign_broadcast_ids(broadcasts)
run_async(run_transmit, transport, broadcasts)
def main():

View File

@@ -35,8 +35,6 @@ from bumble.hci import (
HCI_READ_BUFFER_SIZE_COMMAND,
HCI_READ_LOCAL_NAME_COMMAND,
HCI_SUCCESS,
HCI_VERSION_NAMES,
LMP_VERSION_NAMES,
CodecID,
HCI_Command,
HCI_Command_Complete_Event,
@@ -54,6 +52,7 @@ from bumble.hci import (
HCI_Read_Local_Supported_Codecs_V2_Command,
HCI_Read_Local_Version_Information_Command,
LeFeature,
SpecificationVersion,
map_null_terminated_utf8_string,
)
from bumble.host import Host
@@ -289,14 +288,20 @@ async def async_main(
)
print(
color(' HCI Version: ', 'green'),
name_or_number(HCI_VERSION_NAMES, host.local_version.hci_version),
SpecificationVersion(host.local_version.hci_version).name,
)
print(
color(' HCI Subversion:', 'green'),
f'0x{host.local_version.hci_subversion:04x}',
)
print(color(' HCI Subversion:', 'green'), host.local_version.hci_subversion)
print(
color(' LMP Version: ', 'green'),
name_or_number(LMP_VERSION_NAMES, host.local_version.lmp_version),
SpecificationVersion(host.local_version.lmp_version).name,
)
print(
color(' LMP Subversion:', 'green'),
f'0x{host.local_version.lmp_subversion:04x}',
)
print(color(' LMP Subversion:', 'green'), host.local_version.lmp_subversion)
# Get the Classic info
await get_classic_info(host)

View File

@@ -546,5 +546,6 @@ class SoundDeviceAudioInput(ThreadedAudioInput):
return bytes(pcm_buffer)
def _close(self):
if self._stream:
self._stream.stop()
self._stream = None

View File

@@ -864,8 +864,8 @@ class PeriodicAdvertisingSync(utils.EventEmitter):
EVENT_STATE_CHANGE = "state_change"
EVENT_ESTABLISHMENT = "establishment"
EVENT_ESTABLISHMENT_ERROR = "establishment_error"
EVENT_CANCELLATION = "cancellation"
EVENT_ERROR = "error"
EVENT_LOSS = "loss"
EVENT_PERIODIC_ADVERTISEMENT = "periodic_advertisement"
EVENT_BIGINFO_ADVERTISEMENT = "biginfo_advertisement"
@@ -998,7 +998,7 @@ class PeriodicAdvertisingSync(utils.EventEmitter):
return
self.state = self.State.ERROR
self.emit(self.EVENT_ERROR)
self.emit(self.EVENT_ESTABLISHMENT_ERROR)
def on_loss(self):
self.state = self.State.LOST

View File

@@ -207,22 +207,44 @@ def metadata(
HCI_VENDOR_OGF = 0x3F
# HCI Version
HCI_VERSION_BLUETOOTH_CORE_1_0B = 0
HCI_VERSION_BLUETOOTH_CORE_1_1 = 1
HCI_VERSION_BLUETOOTH_CORE_1_2 = 2
HCI_VERSION_BLUETOOTH_CORE_2_0_EDR = 3
HCI_VERSION_BLUETOOTH_CORE_2_1_EDR = 4
HCI_VERSION_BLUETOOTH_CORE_3_0_HS = 5
HCI_VERSION_BLUETOOTH_CORE_4_0 = 6
HCI_VERSION_BLUETOOTH_CORE_4_1 = 7
HCI_VERSION_BLUETOOTH_CORE_4_2 = 8
HCI_VERSION_BLUETOOTH_CORE_5_0 = 9
HCI_VERSION_BLUETOOTH_CORE_5_1 = 10
HCI_VERSION_BLUETOOTH_CORE_5_2 = 11
HCI_VERSION_BLUETOOTH_CORE_5_3 = 12
HCI_VERSION_BLUETOOTH_CORE_5_4 = 13
HCI_VERSION_BLUETOOTH_CORE_6_0 = 14
# Specification Version
class SpecificationVersion(utils.OpenIntEnum):
BLUETOOTH_CORE_1_0B = 0
BLUETOOTH_CORE_1_1 = 1
BLUETOOTH_CORE_1_2 = 2
BLUETOOTH_CORE_2_0_EDR = 3
BLUETOOTH_CORE_2_1_EDR = 4
BLUETOOTH_CORE_3_0_HS = 5
BLUETOOTH_CORE_4_0 = 6
BLUETOOTH_CORE_4_1 = 7
BLUETOOTH_CORE_4_2 = 8
BLUETOOTH_CORE_5_0 = 9
BLUETOOTH_CORE_5_1 = 10
BLUETOOTH_CORE_5_2 = 11
BLUETOOTH_CORE_5_3 = 12
BLUETOOTH_CORE_5_4 = 13
BLUETOOTH_CORE_6_0 = 14
BLUETOOTH_CORE_6_1 = 15
BLUETOOTH_CORE_6_2 = 16
# For backwards compatibility only
HCI_VERSION_BLUETOOTH_CORE_1_0B = SpecificationVersion.BLUETOOTH_CORE_1_0B
HCI_VERSION_BLUETOOTH_CORE_1_1 = SpecificationVersion.BLUETOOTH_CORE_1_1
HCI_VERSION_BLUETOOTH_CORE_1_2 = SpecificationVersion.BLUETOOTH_CORE_1_2
HCI_VERSION_BLUETOOTH_CORE_2_0_EDR = SpecificationVersion.BLUETOOTH_CORE_2_0_EDR
HCI_VERSION_BLUETOOTH_CORE_2_1_EDR = SpecificationVersion.BLUETOOTH_CORE_2_1_EDR
HCI_VERSION_BLUETOOTH_CORE_3_0_HS = SpecificationVersion.BLUETOOTH_CORE_3_0_HS
HCI_VERSION_BLUETOOTH_CORE_4_0 = SpecificationVersion.BLUETOOTH_CORE_4_0
HCI_VERSION_BLUETOOTH_CORE_4_1 = SpecificationVersion.BLUETOOTH_CORE_4_1
HCI_VERSION_BLUETOOTH_CORE_4_2 = SpecificationVersion.BLUETOOTH_CORE_4_2
HCI_VERSION_BLUETOOTH_CORE_5_0 = SpecificationVersion.BLUETOOTH_CORE_5_0
HCI_VERSION_BLUETOOTH_CORE_5_1 = SpecificationVersion.BLUETOOTH_CORE_5_1
HCI_VERSION_BLUETOOTH_CORE_5_2 = SpecificationVersion.BLUETOOTH_CORE_5_2
HCI_VERSION_BLUETOOTH_CORE_5_3 = SpecificationVersion.BLUETOOTH_CORE_5_3
HCI_VERSION_BLUETOOTH_CORE_5_4 = SpecificationVersion.BLUETOOTH_CORE_5_4
HCI_VERSION_BLUETOOTH_CORE_6_0 = SpecificationVersion.BLUETOOTH_CORE_6_0
HCI_VERSION_BLUETOOTH_CORE_6_1 = SpecificationVersion.BLUETOOTH_CORE_6_1
HCI_VERSION_BLUETOOTH_CORE_6_2 = SpecificationVersion.BLUETOOTH_CORE_6_2
HCI_VERSION_NAMES = {
HCI_VERSION_BLUETOOTH_CORE_1_0B: 'HCI_VERSION_BLUETOOTH_CORE_1_0B',
@@ -240,9 +262,10 @@ HCI_VERSION_NAMES = {
HCI_VERSION_BLUETOOTH_CORE_5_3: 'HCI_VERSION_BLUETOOTH_CORE_5_3',
HCI_VERSION_BLUETOOTH_CORE_5_4: 'HCI_VERSION_BLUETOOTH_CORE_5_4',
HCI_VERSION_BLUETOOTH_CORE_6_0: 'HCI_VERSION_BLUETOOTH_CORE_6_0',
HCI_VERSION_BLUETOOTH_CORE_6_1: 'HCI_VERSION_BLUETOOTH_CORE_6_1',
HCI_VERSION_BLUETOOTH_CORE_6_2: 'HCI_VERSION_BLUETOOTH_CORE_6_2',
}
# LMP Version
LMP_VERSION_NAMES = HCI_VERSION_NAMES
# HCI Packet types

View File

@@ -338,7 +338,12 @@ class BroadcastAudioScanService(gatt.TemplateService):
b"12", # TEST
)
super().__init__([self.battery_level_characteristic])
super().__init__(
[
self.broadcast_audio_scan_control_point_characteristic,
self.broadcast_receive_state_characteristic,
]
)
def on_broadcast_audio_scan_control_point_write(
self, connection: device.Connection, value: bytes

View File

@@ -22,6 +22,7 @@ import enum
from typing_extensions import Self
from bumble import core, data_types, gatt
from bumble.profiles import le_audio
@@ -46,3 +47,18 @@ class PublicBroadcastAnnouncement:
return cls(
features=features, metadata=le_audio.Metadata.from_bytes(metadata_ltv)
)
def get_advertising_data(self) -> bytes:
return bytes(
core.AdvertisingData(
[
data_types.ServiceData16BitUUID(
gatt.GATT_PUBLIC_BROADCAST_ANNOUNCEMENT_SERVICE, bytes(self)
)
]
)
)
def __bytes__(self) -> bytes:
metadata_bytes = bytes(self.metadata)
return bytes([self.features, len(metadata_bytes)]) + metadata_bytes

View File

@@ -104,7 +104,8 @@ The `--output` option specifies where to send the decoded audio samples.
The following outputs are supported:
### Sound Device
The `--output` argument is either `device`, to send the audio to the hosts's default sound device, or `device:<DEVICE_ID>` where `<DEVICE_ID>`
The `--output` argument is either `device`, to send the audio to the hosts's
default sound device, or `device:<DEVICE_ID>` where `<DEVICE_ID>`
is the integer ID of one of the available sound devices.
When invoked with `--output "device:?"`, a list of available devices and
their IDs is printed out.
@@ -115,17 +116,24 @@ standard output (currently always as float32 PCM samples)
### FFPlay
With `--output ffplay`, the decoded audio samples are piped to `ffplay`
in a child process. This option is only available if `ffplay` is a command that is available on the host.
in a child process. This option is only available if `ffplay` is a command
that is available on the host.
### File
With `--output <filename>` or `--output file:<filename>`, the decoded audio
samples are written to a file (currently always as float32 PCM)
## `transmit`
Broadcast an audio source as a transmitter.
Broadcast one or more audio sources as a transmitter.
The `--input` and `--input-format` options specify what audio input
source to transmit.
Optionally, you can use the `--broadcast-list` option,
specifying a TOML configuration file, as a convenient way to specify
audio source configurations for one or more audio sources.
See `examples/auracast_broadcasts.toml` for an example.
The following inputs are supported:
### Sound Device
@@ -146,7 +154,8 @@ are read from a .wav or raw PCM file.
Use the `--input-format <FORMAT>` option to specify the format of the audio
samples in raw PCM files. `<FORMAT>` is expressed as:
`<sample-type>,<sample-rate>,<channels>`
(the only supported <sample-type> currently is 'int16le' for 16 bit signed integers with little-endian byte order)
(the only supported <sample-type> currently is 'int16le' for 16 bit signed
integers with little-endian byte order)
## `scan`
Scan for public broadcasts.
@@ -164,6 +173,7 @@ be shared to allow better compatibiity with certain products.
The `receive` command has been tested to successfully receive broadcasts from
the following transmitters:
* Android's "Audio Sharing"
* JBL GO 4
* Flairmesh FlooGoo FMA120
* Eppfun AK3040Pro Max
@@ -193,10 +203,12 @@ Use the `--manufacturer-data` option of the `transmit` command in order to inclu
that will let the speaker recognize the broadcast as a compatible source.
The manufacturer ID for JBL is 87.
Using an option like `--manufacturer-data 87:00000000000000000000000000000000dffd` should work (tested on the
JBL GO 4. The `dffd` value at the end of the payload may be different on other models?).
Using an option like `--manufacturer-data 87:00000000000000000000000000000000dffd` should work
(tested on the JBL GO 4.
The `dffd` value at the end of the payload may be different on other models?).
### Others
* Android
* Nexum Audio VOCE and USB dongle

View File

@@ -0,0 +1,19 @@
[[broadcasts]]
name = "Broadcast 1"
id = 1234
language="en"
program_info="Jazz"
[[broadcasts.sources]]
input = "file:audio_1_48k.wav"
bitrate = 80000
[[broadcasts]]
name = "Broadcast 2"
id = 5678
language="fr"
program_info="Classical"
[[broadcasts.sources]]
input = "file:audio_2.wav"
[broadcasts.sources.manufacturer_data]
company_id = 87
data = "00000000000000000000000000000000dffd"

View File

@@ -32,6 +32,7 @@ dependencies = [
"pyserial-asyncio >= 0.5; platform_system!='Emscripten'",
"pyserial >= 3.5; platform_system!='Emscripten'",
"pyusb >= 1.2; platform_system!='Emscripten'",
"tomli ~= 2.2.1; platform_system!='Emscripten'",
"websockets >= 15.0.1; platform_system!='Emscripten'",
]