add support for multiple concurrent broadcasts

This commit is contained in:
Gilles Boccon-Gibod
2025-12-05 10:54:23 -05:00
parent b4261548e8
commit 32bb7cdaf3
10 changed files with 868 additions and 512 deletions

View File

@@ -23,10 +23,14 @@ import contextlib
import dataclasses import dataclasses
import functools import functools
import logging import logging
from collections.abc import AsyncGenerator, Coroutine import secrets
from typing import Any from collections.abc import AsyncGenerator, Awaitable, Callable, Iterable, Sequence
from typing import (
Any,
)
import click import click
import tomli
try: try:
import lc3 # type: ignore # pylint: disable=E0401 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_SYNC_TIMEOUT = 5.0
AURACAST_DEFAULT_ATT_MTU = 256 AURACAST_DEFAULT_ATT_MTU = 256
AURACAST_DEFAULT_FRAME_DURATION = 10000 AURACAST_DEFAULT_FRAME_DURATION = 10000
AURACAST_DEFAULT_SAMPLE_RATE = 48000
AURACAST_DEFAULT_TRANSMIT_BITRATE = 80000 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 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 # Scan For Broadcasts
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@@ -119,6 +215,7 @@ class BroadcastScanner(bumble.utils.EventEmitter):
appearance: core.Appearance | None = None appearance: core.Appearance | None = None
biginfo: bumble.device.BigInfoAdvertisement | None = None biginfo: bumble.device.BigInfoAdvertisement | None = None
manufacturer_data: tuple[str, bytes] | None = None manufacturer_data: tuple[str, bytes] | None = None
device_name: str | None = None
def __post_init__(self) -> None: def __post_init__(self) -> None:
super().__init__() super().__init__()
@@ -146,6 +243,10 @@ class BroadcastScanner(bumble.utils.EventEmitter):
) )
continue continue
self.device_name = advertisement.data.get(
core.AdvertisingData.Type.COMPLETE_LOCAL_NAME
)
self.appearance = advertisement.data.get( self.appearance = advertisement.data.get(
core.AdvertisingData.Type.APPEARANCE core.AdvertisingData.Type.APPEARANCE
) )
@@ -170,7 +271,9 @@ class BroadcastScanner(bumble.utils.EventEmitter):
color(self.sync.state.name, 'green'), color(self.sync.state.name, 'green'),
) )
if self.name is not None: 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: if self.appearance:
print(f' {color("Appearance", "cyan")}: {str(self.appearance)}') print(f' {color("Appearance", "cyan")}: {str(self.appearance)}')
print(f' {color("RSSI", "cyan")}: {self.rssi}') print(f' {color("RSSI", "cyan")}: {self.rssi}')
@@ -193,8 +296,13 @@ class BroadcastScanner(bumble.utils.EventEmitter):
f' {color("Features", "cyan")}: ' f' {color("Features", "cyan")}: '
f'{self.public_broadcast_announcement.features.name}' f'{self.public_broadcast_announcement.features.name}'
) )
if self.public_broadcast_announcement.metadata.entries:
print(f' {color("Metadata", "cyan")}: ') print(f' {color("Metadata", "cyan")}: ')
print(self.public_broadcast_announcement.metadata.pretty_print(' ')) print(
self.public_broadcast_announcement.metadata.pretty_print(
' '
)
)
if self.basic_audio_announcement: if self.basic_audio_announcement:
print(color(' Audio:', 'cyan')) print(color(' Audio:', 'cyan'))
@@ -210,6 +318,7 @@ class BroadcastScanner(bumble.utils.EventEmitter):
color(' Coding Format: ', 'green'), color(' Coding Format: ', 'green'),
subgroup.codec_id.codec_id.name, subgroup.codec_id.codec_id.name,
) )
if subgroup.codec_id.company_id:
print( print(
color(' Company ID: ', 'green'), color(' Company ID: ', 'green'),
subgroup.codec_id.company_id, subgroup.codec_id.company_id,
@@ -224,6 +333,7 @@ class BroadcastScanner(bumble.utils.EventEmitter):
subgroup.codec_specific_configuration, ' ' subgroup.codec_specific_configuration, ' '
), ),
) )
if subgroup.metadata.entries:
print(color(' Metadata: ', 'yellow')) print(color(' Metadata: ', 'yellow'))
print(subgroup.metadata.pretty_print(' ')) print(subgroup.metadata.pretty_print(' '))
@@ -292,7 +402,7 @@ class BroadcastScanner(bumble.utils.EventEmitter):
self.device = device self.device = device
self.filter_duplicates = filter_duplicates self.filter_duplicates = filter_duplicates
self.sync_timeout = sync_timeout 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) device.on('advertisement', self.on_advertisement)
async def start(self) -> None: async def start(self) -> None:
@@ -310,7 +420,7 @@ class BroadcastScanner(bumble.utils.EventEmitter):
core.AdvertisingData.Type.SERVICE_DATA_16_BIT_UUID core.AdvertisingData.Type.SERVICE_DATA_16_BIT_UUID
) )
) or not ( ) or not (
broadcast_audio_announcement := next( broadcast_audio_announcement_ad := next(
( (
ad ad
for ad in ads for ad in ads
@@ -325,7 +435,13 @@ class BroadcastScanner(bumble.utils.EventEmitter):
core.AdvertisingData.Type.BROADCAST_NAME 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) broadcast.update(advertisement)
return return
@@ -333,9 +449,7 @@ class BroadcastScanner(bumble.utils.EventEmitter):
self.on_new_broadcast( self.on_new_broadcast(
broadcast_name[0] if broadcast_name else None, broadcast_name[0] if broadcast_name else None,
advertisement, advertisement,
bap.BroadcastAudioAnnouncement.from_bytes( broadcast_audio_announcement.broadcast_id,
broadcast_audio_announcement[1]
).broadcast_id,
) )
) )
@@ -353,12 +467,12 @@ class BroadcastScanner(bumble.utils.EventEmitter):
) )
broadcast = self.Broadcast(name, periodic_advertising_sync, broadcast_id) broadcast = self.Broadcast(name, periodic_advertising_sync, broadcast_id)
broadcast.update(advertisement) 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)) periodic_advertising_sync.on('loss', lambda: self.on_broadcast_loss(broadcast))
self.emit('new_broadcast', broadcast) self.emit('new_broadcast', broadcast)
def on_broadcast_loss(self, broadcast: Broadcast) -> None: def on_broadcast_loss(self, broadcast: BroadcastScanner.Broadcast) -> None:
del self.broadcasts[broadcast.sync.advertiser_address] del self.broadcasts[(broadcast.sync.advertiser_address, broadcast.broadcast_id)]
bumble.utils.AsyncRunner.spawn(broadcast.sync.terminate()) bumble.utils.AsyncRunner.spawn(broadcast.sync.terminate())
self.emit('broadcast_loss', broadcast) self.emit('broadcast_loss', broadcast)
@@ -462,9 +576,8 @@ async def find_broadcast_by_name(
async def run_scan( async def run_scan(
filter_duplicates: bool, sync_timeout: float, transport: str device: bumble.device.Device, filter_duplicates: bool, sync_timeout: float
) -> None: ) -> None:
async with create_device(transport) as device:
if not device.supports_le_periodic_advertising: if not device.supports_le_periodic_advertising:
print(color('Periodic advertising not supported', 'red')) print(color('Periodic advertising not supported', 'red'))
return return
@@ -475,13 +588,12 @@ async def run_scan(
async def run_assist( async def run_assist(
device: bumble.device.Device,
broadcast_name: str | None, broadcast_name: str | None,
source_id: int | None, source_id: int | None,
command: str, command: str,
transport: str,
address: str, address: str,
) -> None: ) -> None:
async with create_device(transport) as device:
if not device.supports_le_periodic_advertising: if not device.supports_le_periodic_advertising:
print(color('Periodic advertising not supported', 'red')) print(color('Periodic advertising not supported', 'red'))
return return
@@ -513,15 +625,14 @@ async def run_assist(
# Subscribe to and read the broadcast receive state characteristics # Subscribe to and read the broadcast receive state characteristics
def on_broadcast_receive_state_update( def on_broadcast_receive_state_update(
value: bass.BroadcastReceiveState, index: int value: bass.BroadcastReceiveState | None, index: int
) -> None: ) -> None:
if value is not None:
print( print(
f"{color(f'Broadcast Receive State Update [{index}]:', 'green')} {value}" f"{color(f'Broadcast Receive State Update [{index}]:', 'green')} {value}"
) )
for i, broadcast_receive_state in enumerate( for i, broadcast_receive_state in enumerate(bass_client.broadcast_receive_states):
bass_client.broadcast_receive_states
):
try: try:
await broadcast_receive_state.subscribe( await broadcast_receive_state.subscribe(
functools.partial(on_broadcast_receive_state_update, index=i) functools.partial(on_broadcast_receive_state_update, index=i)
@@ -535,9 +646,7 @@ async def run_assist(
error, error,
) )
value = await broadcast_receive_state.read_value() value = await broadcast_receive_state.read_value()
print( print(f'{color(f"Initial Broadcast Receive State [{i}]:", "green")} {value}')
f'{color(f"Initial Broadcast Receive State [{i}]:", "green")} {value}'
)
if command == 'monitor-state': if command == 'monitor-state':
await peer.sustain() await peer.sustain()
@@ -645,8 +754,7 @@ async def run_assist(
print(color(f'!!! invalid command {command}')) print(color(f'!!! invalid command {command}'))
async def run_pair(transport: str, address: str) -> None: async def run_pair(device: bumble.device.Device, address: str) -> None:
async with create_device(transport) as device:
# Connect to the server # Connect to the server
print(f'=== Connecting to {address}...') print(f'=== Connecting to {address}...')
async with device.connect_as_gatt(address) as peer: 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( async def run_receive(
transport: str, device: bumble.device.Device,
broadcast_id: int | None, broadcast_id: int | None,
output: str, output: str,
broadcast_code: str | None, broadcast_code: str | None,
@@ -673,7 +781,6 @@ async def run_receive(
print(error) print(error)
return return
async with create_device(transport) as device:
if not device.supports_le_periodic_advertising: if not device.supports_le_periodic_advertising:
print(color('Periodic advertising not supported', 'red')) print(color('Periodic advertising not supported', 'red'))
return return
@@ -741,9 +848,10 @@ async def run_receive(
] ]
packet_stats = [0, 0] packet_stats = [0, 0]
async with contextlib.AsyncExitStack() as stack:
audio_output = await audio_io.create_audio_output(output) 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( await audio_output.open(
audio_io.PcmFormat( audio_io.PcmFormat(
audio_io.PcmFormat.Endianness.LITTLE, audio_io.PcmFormat.Endianness.LITTLE,
@@ -791,52 +899,167 @@ async def run_receive(
terminated = asyncio.Event() terminated = asyncio.Event()
big_sync.on(big_sync.Event.TERMINATION, lambda _: terminated.set()) big_sync.on(big_sync.Event.TERMINATION, lambda _: terminated.set())
await terminated.wait() await terminated.wait()
finally:
await audio_output.aclose()
async def run_transmit( async def run_transmit(
transport: str, device: bumble.device.Device, broadcasts: Iterable[Broadcast]
broadcast_id: int,
broadcast_code: str | None,
broadcast_name: str,
bitrate: int,
manufacturer_data: tuple[int, bytes] | None,
input: str,
input_format: str,
) -> None: ) -> None:
# Run a pre-flight check for the input. # Run a pre-flight check for the input(s).
try: 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 return
except ValueError as error: except ValueError as error:
print(error) print(error)
return return
async with create_device(transport) as device:
if not device.supports_le_periodic_advertising: if not device.supports_le_periodic_advertising:
print(color('Periodic advertising not supported', 'red')) print(color('Periodic advertising not supported', 'red'))
return 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( basic_audio_announcement = bap.BasicAudioAnnouncement(
presentation_delay=40000, presentation_delay=40000,
subgroups=[ subgroups=[
bap.BasicAudioAnnouncement.Subgroup( bap.BasicAudioAnnouncement.Subgroup(
codec_id=hci.CodingFormat(codec_id=hci.CodecID.LC3), codec_id=hci.CodingFormat(codec_id=hci.CodecID.LC3),
codec_specific_configuration=bap.CodecSpecificConfiguration( 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, 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( bap.BasicAudioAnnouncement.BIS(
index=1, index=1,
codec_specific_configuration=bap.CodecSpecificConfiguration( codec_specific_configuration=bap.CodecSpecificConfiguration(
@@ -849,124 +1072,134 @@ async def run_transmit(
audio_channel_allocation=bap.AudioLocation.FRONT_RIGHT 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] = [ 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( 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_set = await device.create_advertising_set(
advertising_parameters=bumble.device.AdvertisingParameters( advertising_parameters=bumble.device.AdvertisingParameters(
advertising_event_properties=bumble.device.AdvertisingEventProperties( advertising_event_properties=bumble.device.AdvertisingEventProperties(
is_connectable=False is_connectable=False
), ),
primary_advertising_interval_min=100, primary_advertising_interval_min=100,
primary_advertising_interval_max=200, primary_advertising_interval_max=1000,
), advertising_sid=broadcast_index,
advertising_data=(
broadcast_audio_announcement.get_advertising_data()
+ bytes(core.AdvertisingData(advertising_data_types))
), ),
advertising_data=advertising_data,
periodic_advertising_parameters=bumble.device.PeriodicAdvertisingParameters( periodic_advertising_parameters=bumble.device.PeriodicAdvertisingParameters(
periodic_advertising_interval_min=80, periodic_advertising_interval_min=100,
periodic_advertising_interval_max=160, periodic_advertising_interval_max=1000,
), ),
periodic_advertising_data=basic_audio_announcement.get_advertising_data(), periodic_advertising_data=basic_audio_announcement.get_advertising_data(),
auto_restart=True, auto_restart=True,
auto_start=True, auto_start=True,
) )
print('Start Periodic Advertising')
await advertising_set.start_periodic() await advertising_set.start_periodic()
async with contextlib.AsyncExitStack() as stack: print('Setting up BIG')
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')
big = await device.create_big( big = await device.create_big(
advertising_set, advertising_set,
parameters=bumble.device.BigParameters( parameters=bumble.device.BigParameters(
num_bis=pcm_format.channels, num_bis=channel_count,
sdu_interval=AURACAST_DEFAULT_FRAME_DURATION, sdu_interval=AURACAST_DEFAULT_FRAME_DURATION,
max_sdu=lc3_frame_size, max_sdu=max_lc3_frame_size,
max_transport_latency=65, max_transport_latency=65,
rtn=4, rtn=4,
broadcast_code=( 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}') print(f'Setup ISO for BIS {bis_link.handle}')
await bis_link.setup_data_path( await bis_link.setup_data_path(
direction=bis_link.Direction.HOST_TO_CONTROLLER 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 = [ print('Transmitting audio from source(s)')
bumble.device.IsoPacketStream(bis_link, 64) while True:
for bis_link in big.bis_links 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_queue_index += 1
iso_queues[0].data_packet_queue.on('flow', on_flow) finally:
for audio_input in audio_inputs:
frame_count = 0 await audio_input.aclose()
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
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: try:
asyncio.run(async_command) asyncio.run(run_with_transport())
except core.ProtocolError as error: except core.ProtocolError as error:
if error.error_namespace == 'att' and error.error_code in list( if error.error_namespace == 'att' and error.error_code in list(
bass.ApplicationError bass.ApplicationError
@@ -1004,7 +1237,7 @@ def auracast(ctx):
@click.pass_context @click.pass_context
def scan(ctx, filter_duplicates, sync_timeout, transport): def scan(ctx, filter_duplicates, sync_timeout, transport):
"""Scan for public broadcasts""" """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') @auracast.command('assist')
@@ -1031,7 +1264,7 @@ def scan(ctx, filter_duplicates, sync_timeout, transport):
@click.pass_context @click.pass_context
def assist(ctx, broadcast_name, source_id, command, transport, address): def assist(ctx, broadcast_name, source_id, command, transport, address):
"""Scan for broadcasts on behalf of an audio server""" """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') @auracast.command('pair')
@@ -1040,7 +1273,7 @@ def assist(ctx, broadcast_name, source_id, command, transport, address):
@click.pass_context @click.pass_context
def pair(ctx, transport, address): def pair(ctx, transport, address):
"""Pair with an audio server""" """Pair with an audio server"""
run_async(run_pair(transport, address)) run_async(run_pair, transport, address)
@auracast.command('receive') @auracast.command('receive')
@@ -1095,7 +1328,7 @@ def receive(
): ):
"""Receive a broadcast source""" """Receive a broadcast source"""
run_async( run_async(
run_receive( run_receive,
transport, transport,
broadcast_id, broadcast_id,
output, output,
@@ -1103,14 +1336,21 @@ def receive(
sync_timeout, sync_timeout,
subgroup, subgroup,
) )
)
@auracast.command('transmit') @auracast.command('transmit')
@click.argument('transport') @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( @click.option(
'--input', '--input',
required=True,
help=( help=(
"Audio input. " "Audio input. "
"'device' -> use the host's default sound input device, " "'device' -> use the host's default sound input device, "
@@ -1139,18 +1379,18 @@ def receive(
'--broadcast-id', '--broadcast-id',
metavar='BROADCAST_ID', metavar='BROADCAST_ID',
type=int, type=int,
default=123456, default=AURACAST_DEFAULT_BROADCAST_ID,
help='Broadcast ID', help='Broadcast ID',
) )
@click.option( @click.option(
'--broadcast-code', '--broadcast-code',
metavar='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( @click.option(
'--broadcast-name', '--broadcast-name',
metavar='BROADCAST_NAME', metavar='BROADCAST_NAME',
default='Bumble Auracast', default=AURACAST_DEFAULT_BROADCAST_NAME,
help='Broadcast name', help='Broadcast name',
) )
@click.option( @click.option(
@@ -1168,15 +1408,43 @@ def receive(
def transmit( def transmit(
ctx, ctx,
transport, transport,
broadcast_id, broadcast_list,
broadcast_code,
manufacturer_data,
broadcast_name,
bitrate,
input, input,
input_format, input_format,
broadcast_id,
broadcast_code,
broadcast_name,
bitrate,
manufacturer_data,
): ):
"""Transmit a broadcast source""" """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: if manufacturer_data:
vendor_id_str, data_hex = manufacturer_data.split(':') vendor_id_str, data_hex = manufacturer_data.split(':')
vendor_id = int(vendor_id_str) vendor_id = int(vendor_id_str)
@@ -1185,22 +1453,28 @@ def transmit(
else: else:
manufacturer_data_tuple = None manufacturer_data_tuple = None
if (input == 'device' or input.startswith('device:')) and input_format == 'auto': broadcasts = [
# Use a default format for device inputs Broadcast(
input_format = 'int16le,48000,1' sources=[
BroadcastSource(
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,
input=input, input=input,
input_format=input_format, 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(): def main():

View File

@@ -35,8 +35,6 @@ from bumble.hci import (
HCI_READ_BUFFER_SIZE_COMMAND, HCI_READ_BUFFER_SIZE_COMMAND,
HCI_READ_LOCAL_NAME_COMMAND, HCI_READ_LOCAL_NAME_COMMAND,
HCI_SUCCESS, HCI_SUCCESS,
HCI_VERSION_NAMES,
LMP_VERSION_NAMES,
CodecID, CodecID,
HCI_Command, HCI_Command,
HCI_Command_Complete_Event, HCI_Command_Complete_Event,
@@ -54,6 +52,7 @@ from bumble.hci import (
HCI_Read_Local_Supported_Codecs_V2_Command, HCI_Read_Local_Supported_Codecs_V2_Command,
HCI_Read_Local_Version_Information_Command, HCI_Read_Local_Version_Information_Command,
LeFeature, LeFeature,
SpecificationVersion,
map_null_terminated_utf8_string, map_null_terminated_utf8_string,
) )
from bumble.host import Host from bumble.host import Host
@@ -289,14 +288,20 @@ async def async_main(
) )
print( print(
color(' HCI Version: ', 'green'), 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( print(
color(' LMP Version: ', 'green'), 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 # Get the Classic info
await get_classic_info(host) await get_classic_info(host)

View File

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

View File

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

View File

@@ -207,22 +207,44 @@ def metadata(
HCI_VENDOR_OGF = 0x3F HCI_VENDOR_OGF = 0x3F
# HCI Version # Specification Version
HCI_VERSION_BLUETOOTH_CORE_1_0B = 0 class SpecificationVersion(utils.OpenIntEnum):
HCI_VERSION_BLUETOOTH_CORE_1_1 = 1 BLUETOOTH_CORE_1_0B = 0
HCI_VERSION_BLUETOOTH_CORE_1_2 = 2 BLUETOOTH_CORE_1_1 = 1
HCI_VERSION_BLUETOOTH_CORE_2_0_EDR = 3 BLUETOOTH_CORE_1_2 = 2
HCI_VERSION_BLUETOOTH_CORE_2_1_EDR = 4 BLUETOOTH_CORE_2_0_EDR = 3
HCI_VERSION_BLUETOOTH_CORE_3_0_HS = 5 BLUETOOTH_CORE_2_1_EDR = 4
HCI_VERSION_BLUETOOTH_CORE_4_0 = 6 BLUETOOTH_CORE_3_0_HS = 5
HCI_VERSION_BLUETOOTH_CORE_4_1 = 7 BLUETOOTH_CORE_4_0 = 6
HCI_VERSION_BLUETOOTH_CORE_4_2 = 8 BLUETOOTH_CORE_4_1 = 7
HCI_VERSION_BLUETOOTH_CORE_5_0 = 9 BLUETOOTH_CORE_4_2 = 8
HCI_VERSION_BLUETOOTH_CORE_5_1 = 10 BLUETOOTH_CORE_5_0 = 9
HCI_VERSION_BLUETOOTH_CORE_5_2 = 11 BLUETOOTH_CORE_5_1 = 10
HCI_VERSION_BLUETOOTH_CORE_5_3 = 12 BLUETOOTH_CORE_5_2 = 11
HCI_VERSION_BLUETOOTH_CORE_5_4 = 13 BLUETOOTH_CORE_5_3 = 12
HCI_VERSION_BLUETOOTH_CORE_6_0 = 14 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_NAMES = {
HCI_VERSION_BLUETOOTH_CORE_1_0B: 'HCI_VERSION_BLUETOOTH_CORE_1_0B', 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_3: 'HCI_VERSION_BLUETOOTH_CORE_5_3',
HCI_VERSION_BLUETOOTH_CORE_5_4: 'HCI_VERSION_BLUETOOTH_CORE_5_4', 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_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 LMP_VERSION_NAMES = HCI_VERSION_NAMES
# HCI Packet types # HCI Packet types

View File

@@ -338,7 +338,12 @@ class BroadcastAudioScanService(gatt.TemplateService):
b"12", # TEST 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( def on_broadcast_audio_scan_control_point_write(
self, connection: device.Connection, value: bytes self, connection: device.Connection, value: bytes

View File

@@ -22,6 +22,7 @@ import enum
from typing_extensions import Self from typing_extensions import Self
from bumble import core, data_types, gatt
from bumble.profiles import le_audio from bumble.profiles import le_audio
@@ -46,3 +47,18 @@ class PublicBroadcastAnnouncement:
return cls( return cls(
features=features, metadata=le_audio.Metadata.from_bytes(metadata_ltv) 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: The following outputs are supported:
### Sound Device ### 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. is the integer ID of one of the available sound devices.
When invoked with `--output "device:?"`, a list of available devices and When invoked with `--output "device:?"`, a list of available devices and
their IDs is printed out. their IDs is printed out.
@@ -115,17 +116,24 @@ standard output (currently always as float32 PCM samples)
### FFPlay ### FFPlay
With `--output ffplay`, the decoded audio samples are piped to `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 ### File
With `--output <filename>` or `--output file:<filename>`, the decoded audio With `--output <filename>` or `--output file:<filename>`, the decoded audio
samples are written to a file (currently always as float32 PCM) samples are written to a file (currently always as float32 PCM)
## `transmit` ## `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 The `--input` and `--input-format` options specify what audio input
source to transmit. 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: The following inputs are supported:
### Sound Device ### 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 Use the `--input-format <FORMAT>` option to specify the format of the audio
samples in raw PCM files. `<FORMAT>` is expressed as: samples in raw PCM files. `<FORMAT>` is expressed as:
`<sample-type>,<sample-rate>,<channels>` `<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`
Scan for public broadcasts. 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 `receive` command has been tested to successfully receive broadcasts from
the following transmitters: the following transmitters:
* Android's "Audio Sharing"
* JBL GO 4 * JBL GO 4
* Flairmesh FlooGoo FMA120 * Flairmesh FlooGoo FMA120
* Eppfun AK3040Pro Max * 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. that will let the speaker recognize the broadcast as a compatible source.
The manufacturer ID for JBL is 87. The manufacturer ID for JBL is 87.
Using an option like `--manufacturer-data 87:00000000000000000000000000000000dffd` should work (tested on the Using an option like `--manufacturer-data 87:00000000000000000000000000000000dffd` should work
JBL GO 4. The `dffd` value at the end of the payload may be different on other models?). (tested on the JBL GO 4.
The `dffd` value at the end of the payload may be different on other models?).
### Others ### Others
* Android
* Nexum Audio VOCE and USB dongle * 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-asyncio >= 0.5; platform_system!='Emscripten'",
"pyserial >= 3.5; platform_system!='Emscripten'", "pyserial >= 3.5; platform_system!='Emscripten'",
"pyusb >= 1.2; platform_system!='Emscripten'", "pyusb >= 1.2; platform_system!='Emscripten'",
"tomli ~= 2.2.1; platform_system!='Emscripten'",
"websockets >= 15.0.1; platform_system!='Emscripten'", "websockets >= 15.0.1; platform_system!='Emscripten'",
] ]