forked from auracaster/bumble_mirror
add support for multiple concurrent broadcasts
This commit is contained in:
584
apps/auracast.py
584
apps/auracast.py
@@ -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}'
|
||||
)
|
||||
if self.public_broadcast_announcement.metadata.entries:
|
||||
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:
|
||||
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():
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -546,5 +546,6 @@ class SoundDeviceAudioInput(ThreadedAudioInput):
|
||||
return bytes(pcm_buffer)
|
||||
|
||||
def _close(self):
|
||||
if self._stream:
|
||||
self._stream.stop()
|
||||
self._stream = None
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
19
examples/auracast_broadcasts.toml
Normal file
19
examples/auracast_broadcasts.toml
Normal 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"
|
||||
@@ -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'",
|
||||
]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user