diff --git a/apps/player/player.py b/apps/player/player.py new file mode 100644 index 00000000..828fa436 --- /dev/null +++ b/apps/player/player.py @@ -0,0 +1,608 @@ +# Copyright 2024 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# ----------------------------------------------------------------------------- +# Imports +# ----------------------------------------------------------------------------- +from __future__ import annotations +import asyncio +import asyncio.subprocess +import os +import logging +from typing import Optional, Union + +import click + +from bumble.a2dp import ( + make_audio_source_service_sdp_records, + A2DP_SBC_CODEC_TYPE, + A2DP_MPEG_2_4_AAC_CODEC_TYPE, + A2DP_NON_A2DP_CODEC_TYPE, + AacFrame, + AacParser, + AacPacketSource, + AacMediaCodecInformation, + SbcFrame, + SbcParser, + SbcPacketSource, + SbcMediaCodecInformation, + OpusPacket, + OpusParser, + OpusPacketSource, + OpusMediaCodecInformation, +) +from bumble.avrcp import Protocol as AvrcpProtocol +from bumble.avdtp import ( + find_avdtp_service_with_connection, + AVDTP_AUDIO_MEDIA_TYPE, + AVDTP_DELAY_REPORTING_SERVICE_CATEGORY, + MediaCodecCapabilities, + MediaPacketPump, + Protocol as AvdtpProtocol, +) +from bumble.colors import color +from bumble.core import ( + AdvertisingData, + ConnectionError as BumbleConnectionError, + DeviceClass, + BT_BR_EDR_TRANSPORT, +) +from bumble.device import Connection, Device, DeviceConfiguration +from bumble.hci import Address, HCI_CONNECTION_ALREADY_EXISTS_ERROR, HCI_Constant +from bumble.pairing import PairingConfig +from bumble.transport import open_transport +from bumble.utils import AsyncRunner + + +# ----------------------------------------------------------------------------- +# Logging +# ----------------------------------------------------------------------------- +logger = logging.getLogger(__name__) + + +# ----------------------------------------------------------------------------- +def a2dp_source_sdp_records(): + service_record_handle = 0x00010001 + return { + service_record_handle: make_audio_source_service_sdp_records( + service_record_handle + ) + } + + +# ----------------------------------------------------------------------------- +async def sbc_codec_capabilities(read_function) -> MediaCodecCapabilities: + sbc_parser = SbcParser(read_function) + sbc_frame: SbcFrame + async for sbc_frame in sbc_parser.frames: + # We only need the first frame + print(color(f"SBC format: {sbc_frame}", "cyan")) + break + + channel_mode = [ + SbcMediaCodecInformation.ChannelMode.MONO, + SbcMediaCodecInformation.ChannelMode.DUAL_CHANNEL, + SbcMediaCodecInformation.ChannelMode.STEREO, + SbcMediaCodecInformation.ChannelMode.JOINT_STEREO, + ][sbc_frame.channel_mode] + block_length = { + 4: SbcMediaCodecInformation.BlockLength.BL_4, + 8: SbcMediaCodecInformation.BlockLength.BL_8, + 12: SbcMediaCodecInformation.BlockLength.BL_12, + 16: SbcMediaCodecInformation.BlockLength.BL_16, + }[sbc_frame.block_count] + subbands = { + 4: SbcMediaCodecInformation.Subbands.S_4, + 8: SbcMediaCodecInformation.Subbands.S_8, + }[sbc_frame.subband_count] + allocation_method = [ + SbcMediaCodecInformation.AllocationMethod.LOUDNESS, + SbcMediaCodecInformation.AllocationMethod.SNR, + ][sbc_frame.allocation_method] + return MediaCodecCapabilities( + media_type=AVDTP_AUDIO_MEDIA_TYPE, + media_codec_type=A2DP_SBC_CODEC_TYPE, + media_codec_information=SbcMediaCodecInformation( + sampling_frequency=SbcMediaCodecInformation.SamplingFrequency.from_int( + sbc_frame.sampling_frequency + ), + channel_mode=channel_mode, + block_length=block_length, + subbands=subbands, + allocation_method=allocation_method, + minimum_bitpool_value=2, + maximum_bitpool_value=40, + ), + ) + + +# ----------------------------------------------------------------------------- +async def aac_codec_capabilities(read_function) -> MediaCodecCapabilities: + aac_parser = AacParser(read_function) + aac_frame: AacFrame + async for aac_frame in aac_parser.frames: + # We only need the first frame + print(color(f"AAC format: {aac_frame}", "cyan")) + break + + sampling_frequency = AacMediaCodecInformation.SamplingFrequency.from_int( + aac_frame.sampling_frequency + ) + channels = ( + AacMediaCodecInformation.Channels.MONO + if aac_frame.channel_configuration == 1 + else AacMediaCodecInformation.Channels.STEREO + ) + + return MediaCodecCapabilities( + media_type=AVDTP_AUDIO_MEDIA_TYPE, + media_codec_type=A2DP_MPEG_2_4_AAC_CODEC_TYPE, + media_codec_information=AacMediaCodecInformation( + object_type=AacMediaCodecInformation.ObjectType.MPEG_2_AAC_LC, + sampling_frequency=sampling_frequency, + channels=channels, + vbr=1, + bitrate=128000, + ), + ) + + +# ----------------------------------------------------------------------------- +async def opus_codec_capabilities(read_function) -> MediaCodecCapabilities: + opus_parser = OpusParser(read_function) + opus_packet: OpusPacket + async for opus_packet in opus_parser.packets: + # We only need the first packet + print(color(f"Opus format: {opus_packet}", "cyan")) + break + + if opus_packet.channel_mode == OpusPacket.ChannelMode.MONO: + channel_mode = OpusMediaCodecInformation.ChannelMode.MONO + elif opus_packet.channel_mode == OpusPacket.ChannelMode.STEREO: + channel_mode = OpusMediaCodecInformation.ChannelMode.STEREO + else: + channel_mode = OpusMediaCodecInformation.ChannelMode.DUAL_MONO + + if opus_packet.duration == 10: + frame_size = OpusMediaCodecInformation.FrameSize.FS_10MS + else: + frame_size = OpusMediaCodecInformation.FrameSize.FS_20MS + + return MediaCodecCapabilities( + media_type=AVDTP_AUDIO_MEDIA_TYPE, + media_codec_type=A2DP_NON_A2DP_CODEC_TYPE, + media_codec_information=OpusMediaCodecInformation( + channel_mode=channel_mode, + sampling_frequency=OpusMediaCodecInformation.SamplingFrequency.SF_48000, + frame_size=frame_size, + ), + ) + + +# ----------------------------------------------------------------------------- +class Player: + def __init__( + self, + transport: str, + device_config: Optional[str], + authenticate: bool, + encrypt: bool, + ) -> None: + self.transport = transport + self.device_config = device_config + self.authenticate = authenticate + self.encrypt = encrypt + self.avrcp_protocol: Optional[AvrcpProtocol] = None + self.done: Optional[asyncio.Event] + + async def run(self, workload) -> None: + self.done = asyncio.Event() + try: + await self._run(workload) + except Exception as error: + print(color(f"!!! ERROR: {error}", "red")) + + async def _run(self, workload) -> None: + async with await open_transport(self.transport) as (hci_source, hci_sink): + # Create a device + device_config = DeviceConfiguration() + if self.device_config: + device_config.load_from_file(self.device_config) + else: + device_config.name = "Bumble Player" + device_config.class_of_device = DeviceClass.pack_class_of_device( + DeviceClass.AUDIO_SERVICE_CLASS, + DeviceClass.AUDIO_VIDEO_MAJOR_DEVICE_CLASS, + DeviceClass.AUDIO_VIDEO_UNCATEGORIZED_MINOR_DEVICE_CLASS, + ) + device_config.keystore = "JsonKeyStore" + + device_config.classic_enabled = True + device_config.le_enabled = False + device_config.le_simultaneous_enabled = False + device_config.classic_sc_enabled = False + device_config.classic_smp_enabled = False + device = Device.from_config_with_hci(device_config, hci_source, hci_sink) + + # Setup the SDP records to expose the SRC service + device.sdp_service_records = a2dp_source_sdp_records() + + # Setup AVRCP + self.avrcp_protocol = AvrcpProtocol() + self.avrcp_protocol.listen(device) + + # Don't require MITM when pairing. + device.pairing_config_factory = lambda connection: PairingConfig(mitm=False) + + # Start the controller + await device.power_on() + + # Print some of the config/properties + print( + "Player Bluetooth Address:", + color( + device.public_address.to_string(with_type_qualifier=False), + "yellow", + ), + ) + + # Listen for connections + device.on("connection", self.on_bluetooth_connection) + + # Run the workload + try: + await workload(device) + except BumbleConnectionError as error: + if error.error_code == HCI_CONNECTION_ALREADY_EXISTS_ERROR: + print(color("Connection already established", "blue")) + else: + print(color(f"Failed to connect: {error}", "red")) + + # Wait until it is time to exit + assert self.done is not None + await asyncio.wait( + [hci_source.terminated, asyncio.ensure_future(self.done.wait())], + return_when=asyncio.FIRST_COMPLETED, + ) + + def on_bluetooth_connection(self, connection: Connection) -> None: + print(color(f"--- Connected: {connection}", "cyan")) + connection.on("disconnection", self.on_bluetooth_disconnection) + + def on_bluetooth_disconnection(self, reason) -> None: + print(color(f"--- Disconnected: {HCI_Constant.error_name(reason)}", "cyan")) + self.set_done() + + async def connect(self, device: Device, address: str) -> Connection: + print(color(f"Connecting to {address}...", "green")) + connection = await device.connect(address, transport=BT_BR_EDR_TRANSPORT) + + # Request authentication + if self.authenticate: + print(color("*** Authenticating...", "blue")) + await connection.authenticate() + print(color("*** Authenticated", "blue")) + + # Enable encryption + if self.encrypt: + print(color("*** Enabling encryption...", "blue")) + await connection.encrypt() + print(color("*** Encryption on", "blue")) + + return connection + + async def create_avdtp_protocol(self, connection: Connection) -> AvdtpProtocol: + # Look for an A2DP service + avdtp_version = await find_avdtp_service_with_connection(connection) + if not avdtp_version: + raise RuntimeError("no A2DP service found") + + print(color(f"AVDTP Version: {avdtp_version}")) + + # Create a client to interact with the remote device + return await AvdtpProtocol.connect(connection, avdtp_version) + + async def stream_packets( + self, + protocol: AvdtpProtocol, + codec_type: int, + vendor_id: int, + codec_id: int, + packet_source: Union[SbcPacketSource, AacPacketSource, OpusPacketSource], + codec_capabilities: MediaCodecCapabilities, + ): + # Discover all endpoints on the remote device + endpoints = await protocol.discover_remote_endpoints() + for endpoint in endpoints: + print('@@@', endpoint) + + # Select a sink + sink = protocol.find_remote_sink_by_codec( + AVDTP_AUDIO_MEDIA_TYPE, codec_type, vendor_id, codec_id + ) + if sink is None: + print(color('!!! no compatible sink found', 'red')) + return + print(f'### Selected sink: {sink.seid}') + + # Check if the sink supports delay reporting + delay_reporting = False + for capability in sink.capabilities: + if capability.service_category == AVDTP_DELAY_REPORTING_SERVICE_CATEGORY: + delay_reporting = True + break + + def on_delay_report(delay: int): + print(color(f"*** DELAY REPORT: {delay}", "blue")) + + # Adjust the codec capabilities for certain codecs + for capability in sink.capabilities: + if isinstance(capability, MediaCodecCapabilities): + if isinstance( + codec_capabilities.media_codec_information, SbcMediaCodecInformation + ) and isinstance( + capability.media_codec_information, SbcMediaCodecInformation + ): + codec_capabilities.media_codec_information.minimum_bitpool_value = ( + capability.media_codec_information.minimum_bitpool_value + ) + codec_capabilities.media_codec_information.maximum_bitpool_value = ( + capability.media_codec_information.maximum_bitpool_value + ) + print(color("Source media codec:", "green"), codec_capabilities) + + # Stream the packets + packet_pump = MediaPacketPump(packet_source.packets) + source = protocol.add_source(codec_capabilities, packet_pump, delay_reporting) + source.on("delay_report", on_delay_report) + stream = await protocol.create_stream(source, sink) + await stream.start() + + await packet_pump.wait_for_completion() + + async def discover(self, device: Device) -> None: + @device.listens_to("inquiry_result") + def on_inquiry_result( + address: Address, class_of_device: int, data: AdvertisingData, rssi: int + ) -> None: + ( + service_classes, + major_device_class, + minor_device_class, + ) = DeviceClass.split_class_of_device(class_of_device) + separator = "\n " + print(f">>> {color(address.to_string(False), 'yellow')}:") + print(f" Device Class (raw): {class_of_device:06X}") + major_class_name = DeviceClass.major_device_class_name(major_device_class) + print(" Device Major Class: " f"{major_class_name}") + minor_class_name = DeviceClass.minor_device_class_name( + major_device_class, minor_device_class + ) + print(" Device Minor Class: " f"{minor_class_name}") + print( + " Device Services: " + f"{', '.join(DeviceClass.service_class_labels(service_classes))}" + ) + print(f" RSSI: {rssi}") + if data.ad_structures: + print(f" {data.to_string(separator)}") + + await device.start_discovery() + + async def pair(self, device: Device, address: str) -> None: + print(color(f"Connecting to {address}...", "green")) + connection = await device.connect(address, transport=BT_BR_EDR_TRANSPORT) + + print(color("Pairing...", "magenta")) + await connection.authenticate() + print(color("Pairing completed", "magenta")) + self.set_done() + + async def inquire(self, device: Device, address: str) -> None: + connection = await self.connect(device, address) + avdtp_protocol = await self.create_avdtp_protocol(connection) + + # Discover the remote endpoints + endpoints = await avdtp_protocol.discover_remote_endpoints() + print(f'@@@ Found {len(list(endpoints))} endpoints') + for endpoint in endpoints: + print('@@@', endpoint) + + self.set_done() + + async def play( + self, + device: Device, + address: Optional[str], + audio_format: str, + audio_file: str, + ) -> None: + if audio_format == "auto": + if audio_file.endswith(".sbc"): + audio_format = "sbc" + elif audio_file.endswith(".aac") or audio_file.endswith(".adts"): + audio_format = "aac" + elif audio_file.endswith(".ogg"): + audio_format = "opus" + else: + raise ValueError("Unable to determine audio format from file extension") + + device.on( + "connection", + lambda connection: AsyncRunner.spawn(on_connection(connection)), + ) + + async def on_connection(connection: Connection): + avdtp_protocol = await self.create_avdtp_protocol(connection) + + with open(audio_file, 'rb') as input_file: + # NOTE: this should be using asyncio file reading, but blocking reads + # are good enough for this command line app. + async def read_audio_data(byte_count): + return input_file.read(byte_count) + + # Obtain the codec capabilities from the stream + packet_source: Union[SbcPacketSource, AacPacketSource, OpusPacketSource] + vendor_id = 0 + codec_id = 0 + if audio_format == "sbc": + codec_type = A2DP_SBC_CODEC_TYPE + codec_capabilities = await sbc_codec_capabilities(read_audio_data) + packet_source = SbcPacketSource( + read_audio_data, + avdtp_protocol.l2cap_channel.peer_mtu, + ) + elif audio_format == "aac": + codec_type = A2DP_MPEG_2_4_AAC_CODEC_TYPE + codec_capabilities = await aac_codec_capabilities(read_audio_data) + packet_source = AacPacketSource( + read_audio_data, + avdtp_protocol.l2cap_channel.peer_mtu, + ) + else: + codec_type = A2DP_NON_A2DP_CODEC_TYPE + vendor_id = OpusMediaCodecInformation.VENDOR_ID + codec_id = OpusMediaCodecInformation.CODEC_ID + codec_capabilities = await opus_codec_capabilities(read_audio_data) + packet_source = OpusPacketSource( + read_audio_data, + avdtp_protocol.l2cap_channel.peer_mtu, + ) + + # Rewind to the start + input_file.seek(0) + + try: + await self.stream_packets( + avdtp_protocol, + codec_type, + vendor_id, + codec_id, + packet_source, + codec_capabilities, + ) + except Exception as error: + print(color(f"!!! Error while streaming: {error}", "red")) + + self.set_done() + + if address: + await self.connect(device, address) + else: + print(color("Waiting for an incoming connection...", "magenta")) + + def set_done(self) -> None: + if self.done: + self.done.set() + + +# ----------------------------------------------------------------------------- +def create_player(context) -> Player: + return Player( + transport=context.obj["hci_transport"], + device_config=context.obj["device_config"], + authenticate=context.obj["authenticate"], + encrypt=context.obj["encrypt"], + ) + + +# ----------------------------------------------------------------------------- +@click.group() +@click.pass_context +@click.option("--hci-transport", metavar="TRANSPORT", required=True) +@click.option("--device-config", metavar="FILENAME", help="Device configuration file") +@click.option( + "--authenticate", + is_flag=True, + help="Request authentication when connecting", + default=False, +) +@click.option( + "--encrypt", is_flag=True, help="Request encryption when connecting", default=True +) +def player_cli(ctx, hci_transport, device_config, authenticate, encrypt): + ctx.ensure_object(dict) + ctx.obj["hci_transport"] = hci_transport + ctx.obj["device_config"] = device_config + ctx.obj["authenticate"] = authenticate + ctx.obj["encrypt"] = encrypt + + +@player_cli.command("discover") +@click.pass_context +def discover(context): + """Discover speakers or headphones""" + player = create_player(context) + asyncio.run(player.run(player.discover)) + + +@player_cli.command("inquire") +@click.pass_context +@click.argument( + "address", + metavar="ADDRESS", +) +def inquire(context, address): + """Connect to a speaker or headphone and inquire about their capabilities""" + player = create_player(context) + asyncio.run(player.run(lambda device: player.inquire(device, address))) + + +@player_cli.command("pair") +@click.pass_context +@click.argument( + "address", + metavar="ADDRESS", +) +def pair(context, address): + """Pair with a speaker or headphone""" + player = create_player(context) + asyncio.run(player.run(lambda device: player.pair(device, address))) + + +@player_cli.command("play") +@click.pass_context +@click.option( + "--connect", + "address", + metavar="ADDRESS", + help="Address or name to connect to", +) +@click.option( + "-f", + "--audio-format", + type=click.Choice(["auto", "sbc", "aac", "opus"]), + help="Audio file format (use 'auto' to infer the format from the file extension)", + default="auto", +) +@click.argument("audio_file") +def play(context, address, audio_format, audio_file): + """Play and audio file""" + player = create_player(context) + asyncio.run( + player.run( + lambda device: player.play(device, address, audio_format, audio_file) + ) + ) + + +# ----------------------------------------------------------------------------- +def main(): + logging.basicConfig(level=os.environ.get("BUMBLE_LOGLEVEL", "WARNING").upper()) + player_cli() + + +# ----------------------------------------------------------------------------- +if __name__ == "__main__": + main() # pylint: disable=no-value-for-parameter diff --git a/apps/speaker/speaker.py b/apps/speaker/speaker.py index fc2230a9..38cd201d 100644 --- a/apps/speaker/speaker.py +++ b/apps/speaker/speaker.py @@ -44,25 +44,18 @@ AVDTP_AUDIO_MEDIA_TYPE, Listener, MediaCodecCapabilities, - MediaPacket, Protocol, ) from bumble.a2dp import ( - MPEG_2_AAC_LC_OBJECT_TYPE, make_audio_sink_service_sdp_records, A2DP_SBC_CODEC_TYPE, A2DP_MPEG_2_4_AAC_CODEC_TYPE, - SBC_MONO_CHANNEL_MODE, - SBC_DUAL_CHANNEL_MODE, - SBC_SNR_ALLOCATION_METHOD, - SBC_LOUDNESS_ALLOCATION_METHOD, - SBC_STEREO_CHANNEL_MODE, - SBC_JOINT_STEREO_CHANNEL_MODE, SbcMediaCodecInformation, AacMediaCodecInformation, ) from bumble.utils import AsyncRunner from bumble.codecs import AacAudioRtpPacket +from bumble.rtp import MediaPacket # ----------------------------------------------------------------------------- @@ -93,7 +86,7 @@ def extract_audio(self, packet: MediaPacket) -> bytes: # ----------------------------------------------------------------------------- class AacAudioExtractor: def extract_audio(self, packet: MediaPacket) -> bytes: - return AacAudioRtpPacket(packet.payload).to_adts() + return AacAudioRtpPacket.from_bytes(packet.payload).to_adts() # ----------------------------------------------------------------------------- @@ -451,10 +444,12 @@ def aac_codec_capabilities(self) -> MediaCodecCapabilities: return MediaCodecCapabilities( media_type=AVDTP_AUDIO_MEDIA_TYPE, media_codec_type=A2DP_MPEG_2_4_AAC_CODEC_TYPE, - media_codec_information=AacMediaCodecInformation.from_lists( - object_types=[MPEG_2_AAC_LC_OBJECT_TYPE], - sampling_frequencies=[48000, 44100], - channels=[1, 2], + media_codec_information=AacMediaCodecInformation( + object_type=AacMediaCodecInformation.ObjectType.MPEG_2_AAC_LC, + sampling_frequency=AacMediaCodecInformation.SamplingFrequency.SF_48000 + | AacMediaCodecInformation.SamplingFrequency.SF_44100, + channels=AacMediaCodecInformation.Channels.MONO + | AacMediaCodecInformation.Channels.STEREO, vbr=1, bitrate=256000, ), @@ -464,20 +459,23 @@ def sbc_codec_capabilities(self) -> MediaCodecCapabilities: return MediaCodecCapabilities( media_type=AVDTP_AUDIO_MEDIA_TYPE, media_codec_type=A2DP_SBC_CODEC_TYPE, - media_codec_information=SbcMediaCodecInformation.from_lists( - sampling_frequencies=[48000, 44100, 32000, 16000], - channel_modes=[ - SBC_MONO_CHANNEL_MODE, - SBC_DUAL_CHANNEL_MODE, - SBC_STEREO_CHANNEL_MODE, - SBC_JOINT_STEREO_CHANNEL_MODE, - ], - block_lengths=[4, 8, 12, 16], - subbands=[4, 8], - allocation_methods=[ - SBC_LOUDNESS_ALLOCATION_METHOD, - SBC_SNR_ALLOCATION_METHOD, - ], + media_codec_information=SbcMediaCodecInformation( + sampling_frequency=SbcMediaCodecInformation.SamplingFrequency.SF_48000 + | SbcMediaCodecInformation.SamplingFrequency.SF_44100 + | SbcMediaCodecInformation.SamplingFrequency.SF_32000 + | SbcMediaCodecInformation.SamplingFrequency.SF_16000, + channel_mode=SbcMediaCodecInformation.ChannelMode.MONO + | SbcMediaCodecInformation.ChannelMode.DUAL_CHANNEL + | SbcMediaCodecInformation.ChannelMode.STEREO + | SbcMediaCodecInformation.ChannelMode.JOINT_STEREO, + block_length=SbcMediaCodecInformation.BlockLength.BL_4 + | SbcMediaCodecInformation.BlockLength.BL_8 + | SbcMediaCodecInformation.BlockLength.BL_12 + | SbcMediaCodecInformation.BlockLength.BL_16, + subbands=SbcMediaCodecInformation.Subbands.S_4 + | SbcMediaCodecInformation.Subbands.S_8, + allocation_method=SbcMediaCodecInformation.AllocationMethod.LOUDNESS + | SbcMediaCodecInformation.AllocationMethod.SNR, minimum_bitpool_value=2, maximum_bitpool_value=53, ), diff --git a/bumble/a2dp.py b/bumble/a2dp.py index cac14e91..6a38981c 100644 --- a/bumble/a2dp.py +++ b/bumble/a2dp.py @@ -17,12 +17,16 @@ # ----------------------------------------------------------------------------- from __future__ import annotations +from collections.abc import AsyncGenerator import dataclasses -import struct +import enum import logging -from collections.abc import AsyncGenerator -from typing import List, Callable, Awaitable +import struct +from typing import Awaitable, Callable +from typing_extensions import ClassVar, Self + +from .codecs import AacAudioRtpPacket from .company_ids import COMPANY_IDENTIFIERS from .sdp import ( DataElement, @@ -42,6 +46,7 @@ BT_ADVANCED_AUDIO_DISTRIBUTION_SERVICE, name_or_number, ) +from .rtp import MediaPacket # ----------------------------------------------------------------------------- @@ -103,6 +108,8 @@ SBC_LOUDNESS_ALLOCATION_METHOD: 'SBC_LOUDNESS_ALLOCATION_METHOD' } +SBC_MAX_FRAMES_IN_RTP_PAYLOAD = 15 + MPEG_2_4_AAC_SAMPLING_FREQUENCIES = [ 8000, 11025, @@ -130,6 +137,9 @@ MPEG_4_AAC_SCALABLE_OBJECT_TYPE: 'MPEG_4_AAC_SCALABLE_OBJECT_TYPE' } + +OPUS_MAX_FRAMES_IN_RTP_PAYLOAD = 15 + # fmt: on @@ -257,38 +267,61 @@ class SbcMediaCodecInformation: A2DP spec - 4.3.2 Codec Specific Information Elements ''' - sampling_frequency: int - channel_mode: int - block_length: int - subbands: int - allocation_method: int + sampling_frequency: SamplingFrequency + channel_mode: ChannelMode + block_length: BlockLength + subbands: Subbands + allocation_method: AllocationMethod minimum_bitpool_value: int maximum_bitpool_value: int - SAMPLING_FREQUENCY_BITS = {16000: 1 << 3, 32000: 1 << 2, 44100: 1 << 1, 48000: 1} - CHANNEL_MODE_BITS = { - SBC_MONO_CHANNEL_MODE: 1 << 3, - SBC_DUAL_CHANNEL_MODE: 1 << 2, - SBC_STEREO_CHANNEL_MODE: 1 << 1, - SBC_JOINT_STEREO_CHANNEL_MODE: 1, - } - BLOCK_LENGTH_BITS = {4: 1 << 3, 8: 1 << 2, 12: 1 << 1, 16: 1} - SUBBANDS_BITS = {4: 1 << 1, 8: 1} - ALLOCATION_METHOD_BITS = { - SBC_SNR_ALLOCATION_METHOD: 1 << 1, - SBC_LOUDNESS_ALLOCATION_METHOD: 1, - } + class SamplingFrequency(enum.IntFlag): + SF_16000 = 1 << 3 + SF_32000 = 1 << 2 + SF_44100 = 1 << 1 + SF_48000 = 1 << 0 + + @classmethod + def from_int(cls, sampling_frequency: int) -> Self: + sampling_frequencies = [ + 16000, + 32000, + 44100, + 48000, + ] + index = sampling_frequencies.index(sampling_frequency) + return cls(1 << (len(sampling_frequencies) - index - 1)) - @staticmethod - def from_bytes(data: bytes) -> SbcMediaCodecInformation: - sampling_frequency = (data[0] >> 4) & 0x0F - channel_mode = (data[0] >> 0) & 0x0F - block_length = (data[1] >> 4) & 0x0F - subbands = (data[1] >> 2) & 0x03 - allocation_method = (data[1] >> 0) & 0x03 + class ChannelMode(enum.IntFlag): + MONO = 1 << 3 + DUAL_CHANNEL = 1 << 2 + STEREO = 1 << 1 + JOINT_STEREO = 1 << 0 + + class BlockLength(enum.IntFlag): + BL_4 = 1 << 3 + BL_8 = 1 << 2 + BL_12 = 1 << 1 + BL_16 = 1 << 0 + + class Subbands(enum.IntFlag): + S_4 = 1 << 1 + S_8 = 1 << 0 + + class AllocationMethod(enum.IntFlag): + SNR = 1 << 1 + LOUDNESS = 1 << 0 + + @classmethod + def from_bytes(cls, data: bytes) -> Self: + sampling_frequency = cls.SamplingFrequency((data[0] >> 4) & 0x0F) + channel_mode = cls.ChannelMode((data[0] >> 0) & 0x0F) + block_length = cls.BlockLength((data[1] >> 4) & 0x0F) + subbands = cls.Subbands((data[1] >> 2) & 0x03) + allocation_method = cls.AllocationMethod((data[1] >> 0) & 0x03) minimum_bitpool_value = (data[2] >> 0) & 0xFF maximum_bitpool_value = (data[3] >> 0) & 0xFF - return SbcMediaCodecInformation( + return cls( sampling_frequency, channel_mode, block_length, @@ -298,52 +331,6 @@ def from_bytes(data: bytes) -> SbcMediaCodecInformation: maximum_bitpool_value, ) - @classmethod - def from_discrete_values( - cls, - sampling_frequency: int, - channel_mode: int, - block_length: int, - subbands: int, - allocation_method: int, - minimum_bitpool_value: int, - maximum_bitpool_value: int, - ) -> SbcMediaCodecInformation: - return SbcMediaCodecInformation( - sampling_frequency=cls.SAMPLING_FREQUENCY_BITS[sampling_frequency], - channel_mode=cls.CHANNEL_MODE_BITS[channel_mode], - block_length=cls.BLOCK_LENGTH_BITS[block_length], - subbands=cls.SUBBANDS_BITS[subbands], - allocation_method=cls.ALLOCATION_METHOD_BITS[allocation_method], - minimum_bitpool_value=minimum_bitpool_value, - maximum_bitpool_value=maximum_bitpool_value, - ) - - @classmethod - def from_lists( - cls, - sampling_frequencies: List[int], - channel_modes: List[int], - block_lengths: List[int], - subbands: List[int], - allocation_methods: List[int], - minimum_bitpool_value: int, - maximum_bitpool_value: int, - ) -> SbcMediaCodecInformation: - return SbcMediaCodecInformation( - sampling_frequency=sum( - cls.SAMPLING_FREQUENCY_BITS[x] for x in sampling_frequencies - ), - channel_mode=sum(cls.CHANNEL_MODE_BITS[x] for x in channel_modes), - block_length=sum(cls.BLOCK_LENGTH_BITS[x] for x in block_lengths), - subbands=sum(cls.SUBBANDS_BITS[x] for x in subbands), - allocation_method=sum( - cls.ALLOCATION_METHOD_BITS[x] for x in allocation_methods - ), - minimum_bitpool_value=minimum_bitpool_value, - maximum_bitpool_value=maximum_bitpool_value, - ) - def __bytes__(self) -> bytes: return bytes( [ @@ -356,23 +343,6 @@ def __bytes__(self) -> bytes: ] ) - def __str__(self) -> str: - channel_modes = ['MONO', 'DUAL_CHANNEL', 'STEREO', 'JOINT_STEREO'] - allocation_methods = ['SNR', 'Loudness'] - return '\n'.join( - # pylint: disable=line-too-long - [ - 'SbcMediaCodecInformation(', - f' sampling_frequency: {",".join([str(x) for x in flags_to_list(self.sampling_frequency, SBC_SAMPLING_FREQUENCIES)])}', - f' channel_mode: {",".join([str(x) for x in flags_to_list(self.channel_mode, channel_modes)])}', - f' block_length: {",".join([str(x) for x in flags_to_list(self.block_length, SBC_BLOCK_LENGTHS)])}', - f' subbands: {",".join([str(x) for x in flags_to_list(self.subbands, SBC_SUBBANDS)])}', - f' allocation_method: {",".join([str(x) for x in flags_to_list(self.allocation_method, allocation_methods)])}', - f' minimum_bitpool_value: {self.minimum_bitpool_value}', - f' maximum_bitpool_value: {self.maximum_bitpool_value}' ')', - ] - ) - # ----------------------------------------------------------------------------- @dataclasses.dataclass @@ -381,83 +351,66 @@ class AacMediaCodecInformation: A2DP spec - 4.5.2 Codec Specific Information Elements ''' - object_type: int - sampling_frequency: int - channels: int - rfa: int + object_type: ObjectType + sampling_frequency: SamplingFrequency + channels: Channels vbr: int bitrate: int - OBJECT_TYPE_BITS = { - MPEG_2_AAC_LC_OBJECT_TYPE: 1 << 7, - MPEG_4_AAC_LC_OBJECT_TYPE: 1 << 6, - MPEG_4_AAC_LTP_OBJECT_TYPE: 1 << 5, - MPEG_4_AAC_SCALABLE_OBJECT_TYPE: 1 << 4, - } - SAMPLING_FREQUENCY_BITS = { - 8000: 1 << 11, - 11025: 1 << 10, - 12000: 1 << 9, - 16000: 1 << 8, - 22050: 1 << 7, - 24000: 1 << 6, - 32000: 1 << 5, - 44100: 1 << 4, - 48000: 1 << 3, - 64000: 1 << 2, - 88200: 1 << 1, - 96000: 1, - } - CHANNELS_BITS = {1: 1 << 1, 2: 1} + class ObjectType(enum.IntFlag): + MPEG_2_AAC_LC = 1 << 7 + MPEG_4_AAC_LC = 1 << 6 + MPEG_4_AAC_LTP = 1 << 5 + MPEG_4_AAC_SCALABLE = 1 << 4 + + class SamplingFrequency(enum.IntFlag): + SF_8000 = 1 << 11 + SF_11025 = 1 << 10 + SF_12000 = 1 << 9 + SF_16000 = 1 << 8 + SF_22050 = 1 << 7 + SF_24000 = 1 << 6 + SF_32000 = 1 << 5 + SF_44100 = 1 << 4 + SF_48000 = 1 << 3 + SF_64000 = 1 << 2 + SF_88200 = 1 << 1 + SF_96000 = 1 << 0 + + @classmethod + def from_int(cls, sampling_frequency: int) -> Self: + sampling_frequencies = [ + 8000, + 11025, + 12000, + 16000, + 22050, + 24000, + 32000, + 44100, + 48000, + 64000, + 88200, + 96000, + ] + index = sampling_frequencies.index(sampling_frequency) + return cls(1 << (len(sampling_frequencies) - index - 1)) - @staticmethod - def from_bytes(data: bytes) -> AacMediaCodecInformation: - object_type = data[0] - sampling_frequency = (data[1] << 4) | ((data[2] >> 4) & 0x0F) - channels = (data[2] >> 2) & 0x03 - rfa = 0 - vbr = (data[3] >> 7) & 0x01 - bitrate = ((data[3] & 0x7F) << 16) | (data[4] << 8) | data[5] - return AacMediaCodecInformation( - object_type, sampling_frequency, channels, rfa, vbr, bitrate - ) + class Channels(enum.IntFlag): + MONO = 1 << 1 + STEREO = 1 << 0 @classmethod - def from_discrete_values( - cls, - object_type: int, - sampling_frequency: int, - channels: int, - vbr: int, - bitrate: int, - ) -> AacMediaCodecInformation: - return AacMediaCodecInformation( - object_type=cls.OBJECT_TYPE_BITS[object_type], - sampling_frequency=cls.SAMPLING_FREQUENCY_BITS[sampling_frequency], - channels=cls.CHANNELS_BITS[channels], - rfa=0, - vbr=vbr, - bitrate=bitrate, + def from_bytes(cls, data: bytes) -> AacMediaCodecInformation: + object_type = cls.ObjectType(data[0]) + sampling_frequency = cls.SamplingFrequency( + (data[1] << 4) | ((data[2] >> 4) & 0x0F) ) - - @classmethod - def from_lists( - cls, - object_types: List[int], - sampling_frequencies: List[int], - channels: List[int], - vbr: int, - bitrate: int, - ) -> AacMediaCodecInformation: + channels = cls.Channels((data[2] >> 2) & 0x03) + vbr = (data[3] >> 7) & 0x01 + bitrate = ((data[3] & 0x7F) << 16) | (data[4] << 8) | data[5] return AacMediaCodecInformation( - object_type=sum(cls.OBJECT_TYPE_BITS[x] for x in object_types), - sampling_frequency=sum( - cls.SAMPLING_FREQUENCY_BITS[x] for x in sampling_frequencies - ), - channels=sum(cls.CHANNELS_BITS[x] for x in channels), - rfa=0, - vbr=vbr, - bitrate=bitrate, + object_type, sampling_frequency, channels, vbr, bitrate ) def __bytes__(self) -> bytes: @@ -472,30 +425,6 @@ def __bytes__(self) -> bytes: ] ) - def __str__(self) -> str: - object_types = [ - 'MPEG_2_AAC_LC', - 'MPEG_4_AAC_LC', - 'MPEG_4_AAC_LTP', - 'MPEG_4_AAC_SCALABLE', - '[4]', - '[5]', - '[6]', - '[7]', - ] - channels = [1, 2] - # pylint: disable=line-too-long - return '\n'.join( - [ - 'AacMediaCodecInformation(', - f' object_type: {",".join([str(x) for x in flags_to_list(self.object_type, object_types)])}', - f' sampling_frequency: {",".join([str(x) for x in flags_to_list(self.sampling_frequency, MPEG_2_4_AAC_SAMPLING_FREQUENCIES)])}', - f' channels: {",".join([str(x) for x in flags_to_list(self.channels, channels)])}', - f' vbr: {self.vbr}', - f' bitrate: {self.bitrate}' ')', - ] - ) - @dataclasses.dataclass # ----------------------------------------------------------------------------- @@ -514,7 +443,7 @@ def from_bytes(data: bytes) -> VendorSpecificMediaCodecInformation: return VendorSpecificMediaCodecInformation(vendor_id, codec_id, data[6:]) def __bytes__(self) -> bytes: - return struct.pack(' str: # pylint: disable=line-too-long @@ -528,13 +457,69 @@ def __str__(self) -> str: ) +# ----------------------------------------------------------------------------- +@dataclasses.dataclass +class OpusMediaCodecInformation(VendorSpecificMediaCodecInformation): + vendor_id: int = dataclasses.field(init=False, repr=False) + codec_id: int = dataclasses.field(init=False, repr=False) + value: bytes = dataclasses.field(init=False, repr=False) + channel_mode: ChannelMode + frame_size: FrameSize + sampling_frequency: SamplingFrequency + + class ChannelMode(enum.IntFlag): + MONO = 1 << 0 + STEREO = 1 << 1 + DUAL_MONO = 1 << 2 + + class FrameSize(enum.IntFlag): + FS_10MS = 1 << 0 + FS_20MS = 1 << 1 + + class SamplingFrequency(enum.IntFlag): + SF_48000 = 1 << 0 + + VENDOR_ID: ClassVar[int] = 0x000000E0 + CODEC_ID: ClassVar[int] = 0x0001 + + def __post_init__(self) -> None: + self.vendor_id = self.VENDOR_ID + self.codec_id = self.CODEC_ID + self.value = bytes( + [ + self.channel_mode + | (self.frame_size << 3) + | (self.sampling_frequency << 7) + ] + ) + + @classmethod + def from_bytes(cls, data: bytes) -> Self: + """Create a new instance from the `value` part of the data, not including + the vendor id and codec id""" + channel_mode = cls.ChannelMode(data[0] & 0x07) + frame_size = cls.FrameSize((data[0] >> 3) & 0x03) + sampling_frequency = cls.SamplingFrequency((data[0] >> 7) & 0x01) + + return cls( + channel_mode, + frame_size, + sampling_frequency, + ) + + def __str__(self) -> str: + return repr(self) + + # ----------------------------------------------------------------------------- @dataclasses.dataclass class SbcFrame: sampling_frequency: int block_count: int channel_mode: int + allocation_method: int subband_count: int + bitpool: int payload: bytes @property @@ -553,8 +538,10 @@ def __str__(self) -> str: return ( f'SBC(sf={self.sampling_frequency},' f'cm={self.channel_mode},' + f'am={self.allocation_method},' f'br={self.bitrate},' f'sc={self.sample_count},' + f'bp={self.bitpool},' f'size={len(self.payload)})' ) @@ -583,6 +570,7 @@ async def generate_frames() -> AsyncGenerator[SbcFrame, None]: blocks = 4 * (1 + ((header[1] >> 4) & 3)) channel_mode = (header[1] >> 2) & 3 channels = 1 if channel_mode == SBC_MONO_CHANNEL_MODE else 2 + allocation_method = (header[1] >> 1) & 1 subbands = 8 if ((header[1]) & 1) else 4 bitpool = header[2] @@ -602,7 +590,13 @@ async def generate_frames() -> AsyncGenerator[SbcFrame, None]: # Emit the next frame yield SbcFrame( - sampling_frequency, blocks, channel_mode, subbands, payload + sampling_frequency, + blocks, + channel_mode, + allocation_method, + subbands, + bitpool, + payload, ) return generate_frames() @@ -610,21 +604,15 @@ async def generate_frames() -> AsyncGenerator[SbcFrame, None]: # ----------------------------------------------------------------------------- class SbcPacketSource: - def __init__( - self, read: Callable[[int], Awaitable[bytes]], mtu: int, codec_capabilities - ) -> None: + def __init__(self, read: Callable[[int], Awaitable[bytes]], mtu: int) -> None: self.read = read self.mtu = mtu - self.codec_capabilities = codec_capabilities @property def packets(self): async def generate_packets(): - # pylint: disable=import-outside-toplevel - from .avdtp import MediaPacket # Import here to avoid a circular reference - sequence_number = 0 - timestamp = 0 + sample_count = 0 frames = [] frames_size = 0 max_rtp_payload = self.mtu - 12 - 1 @@ -632,29 +620,29 @@ async def generate_packets(): # NOTE: this doesn't support frame fragments sbc_parser = SbcParser(self.read) async for frame in sbc_parser.frames: - print(frame) - if ( frames_size + len(frame.payload) > max_rtp_payload - or len(frames) == 16 + or len(frames) == SBC_MAX_FRAMES_IN_RTP_PAYLOAD ): # Need to flush what has been accumulated so far + logger.debug(f"yielding {len(frames)} frames") # Emit a packet - sbc_payload = bytes([len(frames)]) + b''.join( + sbc_payload = bytes([len(frames) & 0x0F]) + b''.join( [frame.payload for frame in frames] ) + timestamp_seconds = sample_count / frame.sampling_frequency + timestamp = int(1000 * timestamp_seconds) packet = MediaPacket( 2, 0, 0, 0, sequence_number, timestamp, 0, [], 96, sbc_payload ) - packet.timestamp_seconds = timestamp / frame.sampling_frequency + packet.timestamp_seconds = timestamp_seconds yield packet # Prepare for next packets sequence_number += 1 sequence_number &= 0xFFFF - timestamp += sum((frame.sample_count for frame in frames)) - timestamp &= 0xFFFFFFFF + sample_count += sum((frame.sample_count for frame in frames)) frames = [frame] frames_size = len(frame.payload) else: @@ -663,3 +651,315 @@ async def generate_packets(): frames_size += len(frame.payload) return generate_packets() + + +# ----------------------------------------------------------------------------- +@dataclasses.dataclass +class AacFrame: + class Profile(enum.IntEnum): + MAIN = 0 + LC = 1 + SSR = 2 + LTP = 3 + + profile: Profile + sampling_frequency: int + channel_configuration: int + payload: bytes + + @property + def sample_count(self) -> int: + return 1024 + + @property + def duration(self) -> float: + return self.sample_count / self.sampling_frequency + + def __str__(self) -> str: + return ( + f'AAC(sf={self.sampling_frequency},' + f'ch={self.channel_configuration},' + f'size={len(self.payload)})' + ) + + +# ----------------------------------------------------------------------------- +ADTS_AAC_SAMPLING_FREQUENCIES = [ + 96000, + 88200, + 64000, + 48000, + 44100, + 32000, + 24000, + 22050, + 16000, + 12000, + 11025, + 8000, + 7350, + 0, + 0, + 0, +] + + +# ----------------------------------------------------------------------------- +class AacParser: + """Parser for AAC frames in an ADTS stream""" + + def __init__(self, read: Callable[[int], Awaitable[bytes]]) -> None: + self.read = read + + @property + def frames(self) -> AsyncGenerator[AacFrame, None]: + async def generate_frames() -> AsyncGenerator[AacFrame, None]: + while True: + header = await self.read(7) + if not header: + return + + sync_word = (header[0] << 4) | (header[1] >> 4) + if sync_word != 0b111111111111: + raise ValueError(f"invalid sync word ({sync_word:06x})") + layer = (header[1] >> 1) & 0b11 + profile = AacFrame.Profile((header[2] >> 6) & 0b11) + sampling_frequency = ADTS_AAC_SAMPLING_FREQUENCIES[ + (header[2] >> 2) & 0b1111 + ] + channel_configuration = ((header[2] & 0b1) << 2) | (header[3] >> 6) + frame_length = ( + ((header[3] & 0b11) << 11) | (header[4] << 3) | (header[5] >> 5) + ) + + if layer != 0: + raise ValueError("layer must be 0") + + payload = await self.read(frame_length - 7) + if payload: + yield AacFrame( + profile, sampling_frequency, channel_configuration, payload + ) + + return generate_frames() + + +# ----------------------------------------------------------------------------- +class AacPacketSource: + def __init__(self, read: Callable[[int], Awaitable[bytes]], mtu: int) -> None: + self.read = read + self.mtu = mtu + + @property + def packets(self): + async def generate_packets(): + sequence_number = 0 + sample_count = 0 + + aac_parser = AacParser(self.read) + async for frame in aac_parser.frames: + logger.debug("yielding one AAC frame") + + # Emit a packet + aac_payload = bytes( + AacAudioRtpPacket.for_simple_aac( + frame.sampling_frequency, + frame.channel_configuration, + frame.payload, + ) + ) + timestamp_seconds = sample_count / frame.sampling_frequency + timestamp = int(1000 * timestamp_seconds) + packet = MediaPacket( + 2, 0, 0, 0, sequence_number, timestamp, 0, [], 96, aac_payload + ) + packet.timestamp_seconds = timestamp_seconds + yield packet + + # Prepare for next packets + sequence_number += 1 + sequence_number &= 0xFFFF + sample_count += frame.sample_count + + return generate_packets() + + +# ----------------------------------------------------------------------------- +@dataclasses.dataclass +class OpusPacket: + class ChannelMode(enum.IntEnum): + MONO = 0 + STEREO = 1 + DUAL_MONO = 2 + + channel_mode: ChannelMode + duration: int # Duration in ms. + sampling_frequency: int + payload: bytes + + def __str__(self) -> str: + return ( + f'Opus(ch={self.channel_mode.name}, ' + f'd={self.duration}ms, ' + f'size={len(self.payload)})' + ) + + +# ----------------------------------------------------------------------------- +class OpusParser: + """ + Parser for Opus packets in an Ogg stream + + See RFC 3533 + + NOTE: this parser only supports bitstreams with a single logical stream. + """ + + CAPTURE_PATTERN = b'OggS' + + class HeaderType(enum.IntFlag): + CONTINUED = 0x01 + FIRST = 0x02 + LAST = 0x04 + + def __init__(self, read: Callable[[int], Awaitable[bytes]]) -> None: + self.read = read + + @property + def packets(self) -> AsyncGenerator[OpusPacket, None]: + async def generate_frames() -> AsyncGenerator[OpusPacket, None]: + packet = b'' + packet_count = 0 + expected_bitstream_serial_number = None + expected_page_sequence_number = 0 + channel_mode = OpusPacket.ChannelMode.STEREO + + while True: + # Parse the page header + header = await self.read(27) + if len(header) != 27: + logger.debug("end of stream") + break + + capture_pattern = header[:4] + if capture_pattern != self.CAPTURE_PATTERN: + print(capture_pattern.hex()) + raise ValueError("invalid capture pattern at start of page") + + version = header[4] + if version != 0: + raise ValueError(f"version {version} not supported") + + header_type = self.HeaderType(header[5]) + ( + granule_position, + bitstream_serial_number, + page_sequence_number, + crc_checksum, + page_segments, + ) = struct.unpack_from(" None: + self.read = read + self.mtu = mtu + + @property + def packets(self): + async def generate_packets(): + sequence_number = 0 + elapsed_ms = 0 + + opus_parser = OpusParser(self.read) + async for opus_packet in opus_parser.packets: + # We only support sending one Opus frame per RTP packet + # TODO: check the spec for the first byte value here + opus_payload = bytes([1]) + opus_packet.payload + elapsed_s = elapsed_ms / 1000 + timestamp = int(elapsed_s * opus_packet.sampling_frequency) + rtp_packet = MediaPacket( + 2, 0, 0, 0, sequence_number, timestamp, 0, [], 96, opus_payload + ) + rtp_packet.timestamp_seconds = elapsed_s + yield rtp_packet + + # Prepare for next packets + sequence_number += 1 + sequence_number &= 0xFFFF + elapsed_ms += opus_packet.duration + + return generate_packets() + + +# ----------------------------------------------------------------------------- +# This map should be left at the end of the file so it can refer to the classes +# above +# ----------------------------------------------------------------------------- +A2DP_VENDOR_MEDIA_CODEC_INFORMATION_CLASSES = { + OpusMediaCodecInformation.VENDOR_ID: { + OpusMediaCodecInformation.CODEC_ID: OpusMediaCodecInformation + } +} diff --git a/bumble/avc.py b/bumble/avc.py index 8e6b968e..81502780 100644 --- a/bumble/avc.py +++ b/bumble/avc.py @@ -119,7 +119,7 @@ def from_bytes(data: bytes) -> Frame: # Not supported raise NotImplementedError("extended subunit types not supported") - if subunit_id < 5: + if subunit_id < 5 or subunit_id == 7: opcode_offset = 2 elif subunit_id == 5: # Extended to the next byte @@ -132,7 +132,6 @@ def from_bytes(data: bytes) -> Frame: else: subunit_id = 5 + extension opcode_offset = 3 - elif subunit_id == 6: raise core.InvalidPacketError("reserved subunit ID") diff --git a/bumble/avdtp.py b/bumble/avdtp.py index fd79dc33..0ef7cf1e 100644 --- a/bumble/avdtp.py +++ b/bumble/avdtp.py @@ -17,12 +17,10 @@ # ----------------------------------------------------------------------------- from __future__ import annotations import asyncio -import struct import time import logging import enum import warnings -from pyee import EventEmitter from typing import ( Any, Awaitable, @@ -39,6 +37,8 @@ cast, ) +from pyee import EventEmitter + from .core import ( BT_ADVANCED_AUDIO_DISTRIBUTION_SERVICE, InvalidStateError, @@ -51,13 +51,16 @@ A2DP_MPEG_2_4_AAC_CODEC_TYPE, A2DP_NON_A2DP_CODEC_TYPE, A2DP_SBC_CODEC_TYPE, + A2DP_VENDOR_MEDIA_CODEC_INFORMATION_CLASSES, AacMediaCodecInformation, SbcMediaCodecInformation, VendorSpecificMediaCodecInformation, ) +from .rtp import MediaPacket from . import sdp, device, l2cap from .colors import color + # ----------------------------------------------------------------------------- # Logging # ----------------------------------------------------------------------------- @@ -278,95 +281,6 @@ async def sleep(self, duration: float) -> None: await asyncio.sleep(duration) -# ----------------------------------------------------------------------------- -class MediaPacket: - @staticmethod - def from_bytes(data: bytes) -> MediaPacket: - version = (data[0] >> 6) & 0x03 - padding = (data[0] >> 5) & 0x01 - extension = (data[0] >> 4) & 0x01 - csrc_count = data[0] & 0x0F - marker = (data[1] >> 7) & 0x01 - payload_type = data[1] & 0x7F - sequence_number = struct.unpack_from('>H', data, 2)[0] - timestamp = struct.unpack_from('>I', data, 4)[0] - ssrc = struct.unpack_from('>I', data, 8)[0] - csrc_list = [ - struct.unpack_from('>I', data, 12 + i)[0] for i in range(csrc_count) - ] - payload = data[12 + csrc_count * 4 :] - - return MediaPacket( - version, - padding, - extension, - marker, - sequence_number, - timestamp, - ssrc, - csrc_list, - payload_type, - payload, - ) - - def __init__( - self, - version: int, - padding: int, - extension: int, - marker: int, - sequence_number: int, - timestamp: int, - ssrc: int, - csrc_list: List[int], - payload_type: int, - payload: bytes, - ) -> None: - self.version = version - self.padding = padding - self.extension = extension - self.marker = marker - self.sequence_number = sequence_number & 0xFFFF - self.timestamp = timestamp & 0xFFFFFFFF - self.ssrc = ssrc - self.csrc_list = csrc_list - self.payload_type = payload_type - self.payload = payload - - def __bytes__(self) -> bytes: - header = bytes( - [ - self.version << 6 - | self.padding << 5 - | self.extension << 4 - | len(self.csrc_list), - self.marker << 7 | self.payload_type, - ] - ) + struct.pack( - '>HII', - self.sequence_number, - self.timestamp, - self.ssrc, - ) - for csrc in self.csrc_list: - header += struct.pack('>I', csrc) - return header + self.payload - - def __str__(self) -> str: - return ( - f'RTP(v={self.version},' - f'p={self.padding},' - f'x={self.extension},' - f'm={self.marker},' - f'pt={self.payload_type},' - f'sn={self.sequence_number},' - f'ts={self.timestamp},' - f'ssrc={self.ssrc},' - f'csrcs={self.csrc_list},' - f'payload_size={len(self.payload)})' - ) - - # ----------------------------------------------------------------------------- class MediaPacketPump: pump_task: Optional[asyncio.Task] @@ -377,6 +291,7 @@ def __init__( self.packets = packets self.clock = clock self.pump_task = None + self.completed = asyncio.Event() async def start(self, rtp_channel: l2cap.ClassicChannel) -> None: async def pump_packets(): @@ -406,6 +321,8 @@ async def pump_packets(): ) except asyncio.exceptions.CancelledError: logger.debug('pump canceled') + finally: + self.completed.set() # Pump packets self.pump_task = asyncio.create_task(pump_packets()) @@ -417,6 +334,9 @@ async def stop(self) -> None: await self.pump_task self.pump_task = None + async def wait_for_completion(self) -> None: + await self.completed.wait() + # ----------------------------------------------------------------------------- class MessageAssembler: @@ -615,11 +535,25 @@ def init_from_bytes(self) -> None: self.media_codec_information ) elif self.media_codec_type == A2DP_NON_A2DP_CODEC_TYPE: - self.media_codec_information = ( + vendor_media_codec_information = ( VendorSpecificMediaCodecInformation.from_bytes( self.media_codec_information ) ) + if ( + vendor_class_map := A2DP_VENDOR_MEDIA_CODEC_INFORMATION_CLASSES.get( + vendor_media_codec_information.vendor_id + ) + ) and ( + media_codec_information_class := vendor_class_map.get( + vendor_media_codec_information.codec_id + ) + ): + self.media_codec_information = media_codec_information_class.from_bytes( + vendor_media_codec_information.value + ) + else: + self.media_codec_information = vendor_media_codec_information def __init__( self, @@ -1316,10 +1250,20 @@ def get_local_endpoint_by_seid(self, seid: int) -> Optional[LocalStreamEndPoint] return None def add_source( - self, codec_capabilities: MediaCodecCapabilities, packet_pump: MediaPacketPump + self, + codec_capabilities: MediaCodecCapabilities, + packet_pump: MediaPacketPump, + delay_reporting: bool = False, ) -> LocalSource: seid = len(self.local_endpoints) + 1 - source = LocalSource(self, seid, codec_capabilities, packet_pump) + service_capabilities = ( + [ServiceCapabilities(AVDTP_DELAY_REPORTING_SERVICE_CATEGORY)] + if delay_reporting + else [] + ) + source = LocalSource( + self, seid, codec_capabilities, service_capabilities, packet_pump + ) self.local_endpoints.append(source) return source @@ -1372,7 +1316,7 @@ async def discover_remote_endpoints(self) -> Iterable[DiscoveredStreamEndPoint]: return self.remote_endpoints.values() def find_remote_sink_by_codec( - self, media_type: int, codec_type: int + self, media_type: int, codec_type: int, vendor_id: int = 0, codec_id: int = 0 ) -> Optional[DiscoveredStreamEndPoint]: for endpoint in self.remote_endpoints.values(): if ( @@ -1397,7 +1341,19 @@ def find_remote_sink_by_codec( codec_capabilities.media_type == AVDTP_AUDIO_MEDIA_TYPE and codec_capabilities.media_codec_type == codec_type ): - has_codec = True + if isinstance( + codec_capabilities.media_codec_information, + VendorSpecificMediaCodecInformation, + ): + if ( + codec_capabilities.media_codec_information.vendor_id + == vendor_id + and codec_capabilities.media_codec_information.codec_id + == codec_id + ): + has_codec = True + else: + has_codec = True if has_media_transport and has_codec: return endpoint @@ -2180,12 +2136,13 @@ def __init__( protocol: Protocol, seid: int, codec_capabilities: MediaCodecCapabilities, + other_capabilitiles: Iterable[ServiceCapabilities], packet_pump: MediaPacketPump, ) -> None: capabilities = [ ServiceCapabilities(AVDTP_MEDIA_TRANSPORT_SERVICE_CATEGORY), codec_capabilities, - ] + ] + list(other_capabilitiles) super().__init__( protocol, seid, diff --git a/bumble/avrcp.py b/bumble/avrcp.py index e06a5a67..4bc625a1 100644 --- a/bumble/avrcp.py +++ b/bumble/avrcp.py @@ -1491,10 +1491,14 @@ def _on_avctp_command( f"<<< AVCTP Command, transaction_label={transaction_label}: " f"{command}" ) - # Only the PANEL subunit type with subunit ID 0 is supported in this profile. - if ( - command.subunit_type != avc.Frame.SubunitType.PANEL - or command.subunit_id != 0 + # Only addressing the unit, or the PANEL subunit with subunit ID 0 is supported + # in this profile. + if not ( + command.subunit_type == avc.Frame.SubunitType.UNIT + and command.subunit_id == 7 + ) and not ( + command.subunit_type == avc.Frame.SubunitType.PANEL + and command.subunit_id == 0 ): logger.debug("subunit not supported") self.send_not_implemented_response(transaction_label, command) @@ -1528,8 +1532,8 @@ def _on_avctp_command( # TODO: delegate response = avc.PassThroughResponseFrame( avc.ResponseFrame.ResponseCode.ACCEPTED, - avc.Frame.SubunitType.PANEL, - 0, + command.subunit_type, + command.subunit_id, command.state_flag, command.operation_id, command.operation_data, @@ -1846,6 +1850,15 @@ def send_rejected_avrcp_response( RejectedResponse(pdu_id, status_code), ) + def send_not_implemented_avrcp_response( + self, transaction_label: int, pdu_id: Protocol.PduId + ) -> None: + self.send_avrcp_response( + transaction_label, + avc.ResponseFrame.ResponseCode.NOT_IMPLEMENTED, + NotImplementedResponse(pdu_id, b''), + ) + def _on_get_capabilities_command( self, transaction_label: int, command: GetCapabilitiesCommand ) -> None: @@ -1891,29 +1904,35 @@ def _on_register_notification_command( async def register_notification(): # Check if the event is supported. supported_events = await self.delegate.get_supported_events() - if command.event_id in supported_events: - if command.event_id == EventId.VOLUME_CHANGED: - volume = await self.delegate.get_absolute_volume() - response = RegisterNotificationResponse(VolumeChangedEvent(volume)) - self.send_avrcp_response( - transaction_label, - avc.ResponseFrame.ResponseCode.INTERIM, - response, - ) - self._register_notification_listener(transaction_label, command) - return + if command.event_id not in supported_events: + logger.debug("event not supported") + self.send_not_implemented_avrcp_response( + transaction_label, self.PduId.REGISTER_NOTIFICATION + ) + return - if command.event_id == EventId.PLAYBACK_STATUS_CHANGED: - # TODO: testing only, use delegate - response = RegisterNotificationResponse( - PlaybackStatusChangedEvent(play_status=PlayStatus.PLAYING) - ) - self.send_avrcp_response( - transaction_label, - avc.ResponseFrame.ResponseCode.INTERIM, - response, - ) - self._register_notification_listener(transaction_label, command) - return + if command.event_id == EventId.VOLUME_CHANGED: + volume = await self.delegate.get_absolute_volume() + response = RegisterNotificationResponse(VolumeChangedEvent(volume)) + self.send_avrcp_response( + transaction_label, + avc.ResponseFrame.ResponseCode.INTERIM, + response, + ) + self._register_notification_listener(transaction_label, command) + return + + if command.event_id == EventId.PLAYBACK_STATUS_CHANGED: + # TODO: testing only, use delegate + response = RegisterNotificationResponse( + PlaybackStatusChangedEvent(play_status=PlayStatus.PLAYING) + ) + self.send_avrcp_response( + transaction_label, + avc.ResponseFrame.ResponseCode.INTERIM, + response, + ) + self._register_notification_listener(transaction_label, command) + return self._delegate_command(transaction_label, command, register_notification()) diff --git a/bumble/codecs.py b/bumble/codecs.py index cfb3cad1..4d4c48cb 100644 --- a/bumble/codecs.py +++ b/bumble/codecs.py @@ -17,6 +17,7 @@ # ----------------------------------------------------------------------------- from __future__ import annotations from dataclasses import dataclass +from typing_extensions import Self from bumble import core @@ -101,12 +102,40 @@ def skip(self, bits: int) -> None: break +# ----------------------------------------------------------------------------- +class BitWriter: + """Simple but not optimized bit stream writer.""" + + data: int + bit_count: int + + def __init__(self) -> None: + self.data = 0 + self.bit_count = 0 + + def write(self, value: int, bit_count: int) -> None: + self.data = (self.data << bit_count) | value + self.bit_count += bit_count + + def write_bytes(self, data: bytes) -> None: + bit_count = 8 * len(data) + self.data = (self.data << bit_count) | int.from_bytes(data, 'big') + self.bit_count += bit_count + + def __bytes__(self) -> bytes: + return (self.data << ((8 - (self.bit_count % 8)) % 8)).to_bytes( + (self.bit_count + 7) // 8, 'big' + ) + + # ----------------------------------------------------------------------------- class AacAudioRtpPacket: """AAC payload encapsulated in an RTP packet payload""" + audio_mux_element: AudioMuxElement + @staticmethod - def latm_value(reader: BitReader) -> int: + def read_latm_value(reader: BitReader) -> int: bytes_for_value = reader.read(2) value = 0 for _ in range(bytes_for_value + 1): @@ -114,24 +143,33 @@ def latm_value(reader: BitReader) -> int: return value @staticmethod - def program_config_element(reader: BitReader): - raise core.InvalidPacketError('program_config_element not supported') + def read_audio_object_type(reader: BitReader): + # GetAudioObjectType - ISO/EIC 14496-3 Table 1.16 + audio_object_type = reader.read(5) + if audio_object_type == 31: + audio_object_type = 32 + reader.read(6) + + return audio_object_type @dataclass class GASpecificConfig: - def __init__( - self, reader: BitReader, channel_configuration: int, audio_object_type: int - ) -> None: + audio_object_type: int + # NOTE: other fields not supported + + @classmethod + def from_bits( + cls, reader: BitReader, channel_configuration: int, audio_object_type: int + ) -> Self: # GASpecificConfig - ISO/EIC 14496-3 Table 4.1 frame_length_flag = reader.read(1) depends_on_core_coder = reader.read(1) if depends_on_core_coder: - self.core_coder_delay = reader.read(14) + core_coder_delay = reader.read(14) extension_flag = reader.read(1) if not channel_configuration: - AacAudioRtpPacket.program_config_element(reader) + raise core.InvalidPacketError('program_config_element not supported') if audio_object_type in (6, 20): - self.layer_nr = reader.read(3) + layer_nr = reader.read(3) if extension_flag: if audio_object_type == 22: num_of_sub_frame = reader.read(5) @@ -144,14 +182,13 @@ def __init__( if extension_flag_3 == 1: raise core.InvalidPacketError('extensionFlag3 == 1 not supported') - @staticmethod - def audio_object_type(reader: BitReader): - # GetAudioObjectType - ISO/EIC 14496-3 Table 1.16 - audio_object_type = reader.read(5) - if audio_object_type == 31: - audio_object_type = 32 + reader.read(6) + return cls(audio_object_type) - return audio_object_type + def to_bits(self, writer: BitWriter) -> None: + assert self.audio_object_type in (1, 2) + writer.write(0, 1) # frame_length_flag = 0 + writer.write(0, 1) # depends_on_core_coder = 0 + writer.write(0, 1) # extension_flag = 0 @dataclass class AudioSpecificConfig: @@ -159,6 +196,7 @@ class AudioSpecificConfig: sampling_frequency_index: int sampling_frequency: int channel_configuration: int + ga_specific_config: AacAudioRtpPacket.GASpecificConfig sbr_present_flag: int ps_present_flag: int extension_audio_object_type: int @@ -182,44 +220,73 @@ class AudioSpecificConfig: 7350, ] - def __init__(self, reader: BitReader) -> None: + @classmethod + def for_simple_aac( + cls, + audio_object_type: int, + sampling_frequency: int, + channel_configuration: int, + ) -> Self: + if sampling_frequency not in cls.SAMPLING_FREQUENCIES: + raise ValueError(f'invalid sampling frequency {sampling_frequency}') + + ga_specific_config = AacAudioRtpPacket.GASpecificConfig(audio_object_type) + + return cls( + audio_object_type=audio_object_type, + sampling_frequency_index=cls.SAMPLING_FREQUENCIES.index( + sampling_frequency + ), + sampling_frequency=sampling_frequency, + channel_configuration=channel_configuration, + ga_specific_config=ga_specific_config, + sbr_present_flag=0, + ps_present_flag=0, + extension_audio_object_type=0, + extension_sampling_frequency_index=0, + extension_sampling_frequency=0, + extension_channel_configuration=0, + ) + + @classmethod + def from_bits(cls, reader: BitReader) -> Self: # AudioSpecificConfig - ISO/EIC 14496-3 Table 1.15 - self.audio_object_type = AacAudioRtpPacket.audio_object_type(reader) - self.sampling_frequency_index = reader.read(4) - if self.sampling_frequency_index == 0xF: - self.sampling_frequency = reader.read(24) + audio_object_type = AacAudioRtpPacket.read_audio_object_type(reader) + sampling_frequency_index = reader.read(4) + if sampling_frequency_index == 0xF: + sampling_frequency = reader.read(24) else: - self.sampling_frequency = self.SAMPLING_FREQUENCIES[ - self.sampling_frequency_index - ] - self.channel_configuration = reader.read(4) - self.sbr_present_flag = -1 - self.ps_present_flag = -1 - if self.audio_object_type in (5, 29): - self.extension_audio_object_type = 5 - self.sbc_present_flag = 1 - if self.audio_object_type == 29: - self.ps_present_flag = 1 - self.extension_sampling_frequency_index = reader.read(4) - if self.extension_sampling_frequency_index == 0xF: - self.extension_sampling_frequency = reader.read(24) + sampling_frequency = cls.SAMPLING_FREQUENCIES[sampling_frequency_index] + channel_configuration = reader.read(4) + sbr_present_flag = 0 + ps_present_flag = 0 + extension_sampling_frequency_index = 0 + extension_sampling_frequency = 0 + extension_channel_configuration = 0 + extension_audio_object_type = 0 + if audio_object_type in (5, 29): + extension_audio_object_type = 5 + sbr_present_flag = 1 + if audio_object_type == 29: + ps_present_flag = 1 + extension_sampling_frequency_index = reader.read(4) + if extension_sampling_frequency_index == 0xF: + extension_sampling_frequency = reader.read(24) else: - self.extension_sampling_frequency = self.SAMPLING_FREQUENCIES[ - self.extension_sampling_frequency_index + extension_sampling_frequency = cls.SAMPLING_FREQUENCIES[ + extension_sampling_frequency_index ] - self.audio_object_type = AacAudioRtpPacket.audio_object_type(reader) - if self.audio_object_type == 22: - self.extension_channel_configuration = reader.read(4) - else: - self.extension_audio_object_type = 0 + audio_object_type = AacAudioRtpPacket.read_audio_object_type(reader) + if audio_object_type == 22: + extension_channel_configuration = reader.read(4) - if self.audio_object_type in (1, 2, 3, 4, 6, 7, 17, 19, 20, 21, 22, 23): - ga_specific_config = AacAudioRtpPacket.GASpecificConfig( - reader, self.channel_configuration, self.audio_object_type + if audio_object_type in (1, 2, 3, 4, 6, 7, 17, 19, 20, 21, 22, 23): + ga_specific_config = AacAudioRtpPacket.GASpecificConfig.from_bits( + reader, channel_configuration, audio_object_type ) else: raise core.InvalidPacketError( - f'audioObjectType {self.audio_object_type} not supported' + f'audioObjectType {audio_object_type} not supported' ) # if self.extension_audio_object_type != 5 and bits_to_decode >= 16: @@ -248,13 +315,44 @@ def __init__(self, reader: BitReader) -> None: # self.extension_sampling_frequency = self.SAMPLING_FREQUENCIES[self.extension_sampling_frequency_index] # self.extension_channel_configuration = reader.read(4) + return cls( + audio_object_type, + sampling_frequency_index, + sampling_frequency, + channel_configuration, + ga_specific_config, + sbr_present_flag, + ps_present_flag, + extension_audio_object_type, + extension_sampling_frequency_index, + extension_sampling_frequency, + extension_channel_configuration, + ) + + def to_bits(self, writer: BitWriter) -> None: + if self.sampling_frequency_index >= 15: + raise ValueError( + f"unsupported sampling frequency index {self.sampling_frequency_index}" + ) + + if self.audio_object_type not in (1, 2): + raise ValueError( + f"unsupported audio object type {self.audio_object_type} " + ) + + writer.write(self.audio_object_type, 5) + writer.write(self.sampling_frequency_index, 4) + writer.write(self.channel_configuration, 4) + self.ga_specific_config.to_bits(writer) + @dataclass class StreamMuxConfig: other_data_present: int other_data_len_bits: int audio_specific_config: AacAudioRtpPacket.AudioSpecificConfig - def __init__(self, reader: BitReader) -> None: + @classmethod + def from_bits(cls, reader: BitReader) -> Self: # StreamMuxConfig - ISO/EIC 14496-3 Table 1.42 audio_mux_version = reader.read(1) if audio_mux_version == 1: @@ -264,7 +362,7 @@ def __init__(self, reader: BitReader) -> None: if audio_mux_version_a != 0: raise core.InvalidPacketError('audioMuxVersionA != 0 not supported') if audio_mux_version == 1: - tara_buffer_fullness = AacAudioRtpPacket.latm_value(reader) + tara_buffer_fullness = AacAudioRtpPacket.read_latm_value(reader) stream_cnt = 0 all_streams_same_time_framing = reader.read(1) num_sub_frames = reader.read(6) @@ -275,13 +373,13 @@ def __init__(self, reader: BitReader) -> None: if num_layer != 0: raise core.InvalidPacketError('num_layer != 0 not supported') if audio_mux_version == 0: - self.audio_specific_config = AacAudioRtpPacket.AudioSpecificConfig( + audio_specific_config = AacAudioRtpPacket.AudioSpecificConfig.from_bits( reader ) else: - asc_len = AacAudioRtpPacket.latm_value(reader) + asc_len = AacAudioRtpPacket.read_latm_value(reader) marker = reader.bit_position - self.audio_specific_config = AacAudioRtpPacket.AudioSpecificConfig( + audio_specific_config = AacAudioRtpPacket.AudioSpecificConfig.from_bits( reader ) audio_specific_config_len = reader.bit_position - marker @@ -299,36 +397,49 @@ def __init__(self, reader: BitReader) -> None: f'frame_length_type {frame_length_type} not supported' ) - self.other_data_present = reader.read(1) - if self.other_data_present: + other_data_present = reader.read(1) + other_data_len_bits = 0 + if other_data_present: if audio_mux_version == 1: - self.other_data_len_bits = AacAudioRtpPacket.latm_value(reader) + other_data_len_bits = AacAudioRtpPacket.read_latm_value(reader) else: - self.other_data_len_bits = 0 while True: - self.other_data_len_bits *= 256 + other_data_len_bits *= 256 other_data_len_esc = reader.read(1) - self.other_data_len_bits += reader.read(8) + other_data_len_bits += reader.read(8) if other_data_len_esc == 0: break crc_check_present = reader.read(1) if crc_check_present: crc_checksum = reader.read(8) + return cls(other_data_present, other_data_len_bits, audio_specific_config) + + def to_bits(self, writer: BitWriter) -> None: + writer.write(0, 1) # audioMuxVersion = 0 + writer.write(1, 1) # allStreamsSameTimeFraming = 1 + writer.write(0, 6) # numSubFrames = 0 + writer.write(0, 4) # numProgram = 0 + writer.write(0, 3) # numLayer = 0 + self.audio_specific_config.to_bits(writer) + writer.write(0, 3) # frameLengthType = 0 + writer.write(0, 8) # latmBufferFullness = 0 + writer.write(0, 1) # otherDataPresent = 0 + writer.write(0, 1) # crcCheckPresent = 0 + @dataclass class AudioMuxElement: - payload: bytes stream_mux_config: AacAudioRtpPacket.StreamMuxConfig + payload: bytes - def __init__(self, reader: BitReader, mux_config_present: int): - if mux_config_present == 0: - raise core.InvalidPacketError('muxConfigPresent == 0 not supported') - + @classmethod + def from_bits(cls, reader: BitReader) -> Self: # AudioMuxElement - ISO/EIC 14496-3 Table 1.41 + # (only supports mux_config_present=1) use_same_stream_mux = reader.read(1) if use_same_stream_mux: raise core.InvalidPacketError('useSameStreamMux == 1 not supported') - self.stream_mux_config = AacAudioRtpPacket.StreamMuxConfig(reader) + stream_mux_config = AacAudioRtpPacket.StreamMuxConfig.from_bits(reader) # We only support: # allStreamsSameTimeFraming == 1 @@ -344,19 +455,46 @@ def __init__(self, reader: BitReader, mux_config_present: int): if tmp != 255: break - self.payload = reader.read_bytes(mux_slot_length_bytes) + payload = reader.read_bytes(mux_slot_length_bytes) - if self.stream_mux_config.other_data_present: - reader.skip(self.stream_mux_config.other_data_len_bits) + if stream_mux_config.other_data_present: + reader.skip(stream_mux_config.other_data_len_bits) # ByteAlign while reader.bit_position % 8: reader.read(1) - def __init__(self, data: bytes) -> None: + return cls(stream_mux_config, payload) + + def to_bits(self, writer: BitWriter) -> None: + writer.write(0, 1) # useSameStreamMux = 0 + self.stream_mux_config.to_bits(writer) + mux_slot_length_bytes = len(self.payload) + while mux_slot_length_bytes > 255: + writer.write(255, 8) + mux_slot_length_bytes -= 255 + writer.write(mux_slot_length_bytes, 8) + if mux_slot_length_bytes == 255: + writer.write(0, 8) + writer.write_bytes(self.payload) + + @classmethod + def from_bytes(cls, data: bytes) -> Self: # Parse the bit stream reader = BitReader(data) - self.audio_mux_element = self.AudioMuxElement(reader, mux_config_present=1) + return cls(cls.AudioMuxElement.from_bits(reader)) + + @classmethod + def for_simple_aac( + cls, sampling_frequency: int, channel_configuration: int, payload: bytes + ) -> Self: + audio_specific_config = cls.AudioSpecificConfig.for_simple_aac( + 2, sampling_frequency, channel_configuration + ) + stream_mux_config = cls.StreamMuxConfig(0, 0, audio_specific_config) + audio_mux_element = cls.AudioMuxElement(stream_mux_config, payload) + + return cls(audio_mux_element) def to_adts(self): # pylint: disable=line-too-long @@ -383,3 +521,11 @@ def to_adts(self): ) + self.audio_mux_element.payload ) + + def __init__(self, audio_mux_element: AudioMuxElement) -> None: + self.audio_mux_element = audio_mux_element + + def __bytes__(self) -> bytes: + writer = BitWriter() + self.audio_mux_element.to_bits(writer) + return bytes(writer) diff --git a/bumble/device.py b/bumble/device.py index 38d0ca6f..07f44c08 100644 --- a/bumble/device.py +++ b/bumble/device.py @@ -1571,14 +1571,22 @@ async def __aexit__(self, exc_type, exc_value, traceback): raise def __str__(self): - return ( - f'Connection(handle=0x{self.handle:04X}, ' - f'role={self.role_name}, ' - f'self_address={self.self_address}, ' - f'self_resolvable_address={self.self_resolvable_address}, ' - f'peer_address={self.peer_address}, ' - f'peer_resolvable_address={self.peer_resolvable_address})' - ) + if self.transport == BT_LE_TRANSPORT: + return ( + f'Connection(transport=LE, handle=0x{self.handle:04X}, ' + f'role={self.role_name}, ' + f'self_address={self.self_address}, ' + f'self_resolvable_address={self.self_resolvable_address}, ' + f'peer_address={self.peer_address}, ' + f'peer_resolvable_address={self.peer_resolvable_address})' + ) + else: + return ( + f'Connection(transport=BR/EDR, handle=0x{self.handle:04X}, ' + f'role={self.role_name}, ' + f'self_address={self.self_address}, ' + f'peer_address={self.peer_address})' + ) # ----------------------------------------------------------------------------- diff --git a/bumble/hci.py b/bumble/hci.py index f79098aa..483f5c87 100644 --- a/bumble/hci.py +++ b/bumble/hci.py @@ -6065,6 +6065,32 @@ class HCI_Read_Remote_Version_Information_Complete_Event(HCI_Event): ''' +# ----------------------------------------------------------------------------- +@HCI_Event.event( + [ + ('status', STATUS_SPEC), + ('connection_handle', 2), + ('unused', 1), + ( + 'service_type', + { + 'size': 1, + 'mapper': lambda x: HCI_QOS_Setup_Complete_Event.ServiceType(x).name, + }, + ), + ] +) +class HCI_QOS_Setup_Complete_Event(HCI_Event): + ''' + See Bluetooth spec @ 7.7.13 QoS Setup Complete Event + ''' + + class ServiceType(OpenIntEnum): + NO_TRAFFIC_AVAILABLE = 0x00 + BEST_EFFORT_AVAILABLE = 0x01 + GUARANTEED_AVAILABLE = 0x02 + + # ----------------------------------------------------------------------------- @HCI_Event.event( [ diff --git a/bumble/host.py b/bumble/host.py index a3d3dad3..45cdee72 100644 --- a/bumble/host.py +++ b/bumble/host.py @@ -1106,6 +1106,18 @@ def on_hci_encryption_key_refresh_complete_event(self, event): event.status, ) + def on_hci_qos_setup_complete_event(self, event): + if event.status == hci.HCI_SUCCESS: + self.emit( + 'connection_qos_setup', event.connection_handle, event.service_type + ) + else: + self.emit( + 'connection_qos_setup_failure', + event.connection_handle, + event.status, + ) + def on_hci_link_supervision_timeout_changed_event(self, event): pass diff --git a/bumble/rtp.py b/bumble/rtp.py new file mode 100644 index 00000000..4ec68dbd --- /dev/null +++ b/bumble/rtp.py @@ -0,0 +1,110 @@ +# Copyright 2024 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# ----------------------------------------------------------------------------- +# Imports +# ----------------------------------------------------------------------------- +from __future__ import annotations +import struct +from typing import List + + +# ----------------------------------------------------------------------------- +class MediaPacket: + @staticmethod + def from_bytes(data: bytes) -> MediaPacket: + version = (data[0] >> 6) & 0x03 + padding = (data[0] >> 5) & 0x01 + extension = (data[0] >> 4) & 0x01 + csrc_count = data[0] & 0x0F + marker = (data[1] >> 7) & 0x01 + payload_type = data[1] & 0x7F + sequence_number = struct.unpack_from('>H', data, 2)[0] + timestamp = struct.unpack_from('>I', data, 4)[0] + ssrc = struct.unpack_from('>I', data, 8)[0] + csrc_list = [ + struct.unpack_from('>I', data, 12 + i)[0] for i in range(csrc_count) + ] + payload = data[12 + csrc_count * 4 :] + + return MediaPacket( + version, + padding, + extension, + marker, + sequence_number, + timestamp, + ssrc, + csrc_list, + payload_type, + payload, + ) + + def __init__( + self, + version: int, + padding: int, + extension: int, + marker: int, + sequence_number: int, + timestamp: int, + ssrc: int, + csrc_list: List[int], + payload_type: int, + payload: bytes, + ) -> None: + self.version = version + self.padding = padding + self.extension = extension + self.marker = marker + self.sequence_number = sequence_number & 0xFFFF + self.timestamp = timestamp & 0xFFFFFFFF + self.timestamp_seconds = 0.0 + self.ssrc = ssrc + self.csrc_list = csrc_list + self.payload_type = payload_type + self.payload = payload + + def __bytes__(self) -> bytes: + header = bytes( + [ + self.version << 6 + | self.padding << 5 + | self.extension << 4 + | len(self.csrc_list), + self.marker << 7 | self.payload_type, + ] + ) + struct.pack( + '>HII', + self.sequence_number, + self.timestamp, + self.ssrc, + ) + for csrc in self.csrc_list: + header += struct.pack('>I', csrc) + return header + self.payload + + def __str__(self) -> str: + return ( + f'RTP(v={self.version},' + f'p={self.padding},' + f'x={self.extension},' + f'm={self.marker},' + f'pt={self.payload_type},' + f'sn={self.sequence_number},' + f'ts={self.timestamp},' + f'ssrc={self.ssrc},' + f'csrcs={self.csrc_list},' + f'payload_size={len(self.payload)})' + ) diff --git a/examples/run_a2dp_sink.py b/examples/run_a2dp_sink.py index ca1af84f..aa0a152a 100644 --- a/examples/run_a2dp_sink.py +++ b/examples/run_a2dp_sink.py @@ -61,20 +61,23 @@ def codec_capabilities(): return MediaCodecCapabilities( media_type=AVDTP_AUDIO_MEDIA_TYPE, media_codec_type=A2DP_SBC_CODEC_TYPE, - media_codec_information=SbcMediaCodecInformation.from_lists( - sampling_frequencies=[48000, 44100, 32000, 16000], - channel_modes=[ - SBC_MONO_CHANNEL_MODE, - SBC_DUAL_CHANNEL_MODE, - SBC_STEREO_CHANNEL_MODE, - SBC_JOINT_STEREO_CHANNEL_MODE, - ], - block_lengths=[4, 8, 12, 16], - subbands=[4, 8], - allocation_methods=[ - SBC_LOUDNESS_ALLOCATION_METHOD, - SBC_SNR_ALLOCATION_METHOD, - ], + media_codec_information=SbcMediaCodecInformation( + sampling_frequency=SbcMediaCodecInformation.SamplingFrequency.SF_48000 + | SbcMediaCodecInformation.SamplingFrequency.SF_44100 + | SbcMediaCodecInformation.SamplingFrequency.SF_32000 + | SbcMediaCodecInformation.SamplingFrequency.SF_16000, + channel_mode=SbcMediaCodecInformation.ChannelMode.MONO + | SbcMediaCodecInformation.ChannelMode.DUAL_CHANNEL + | SbcMediaCodecInformation.ChannelMode.STEREO + | SbcMediaCodecInformation.ChannelMode.JOINT_STEREO, + block_length=SbcMediaCodecInformation.BlockLength.BL_4 + | SbcMediaCodecInformation.BlockLength.BL_8 + | SbcMediaCodecInformation.BlockLength.BL_12 + | SbcMediaCodecInformation.BlockLength.BL_16, + subbands=SbcMediaCodecInformation.Subbands.S_4 + | SbcMediaCodecInformation.Subbands.S_8, + allocation_method=SbcMediaCodecInformation.AllocationMethod.LOUDNESS + | SbcMediaCodecInformation.AllocationMethod.SNR, minimum_bitpool_value=2, maximum_bitpool_value=53, ), diff --git a/examples/run_a2dp_source.py b/examples/run_a2dp_source.py index a1f955b5..20182e1c 100644 --- a/examples/run_a2dp_source.py +++ b/examples/run_a2dp_source.py @@ -33,8 +33,6 @@ Listener, ) from bumble.a2dp import ( - SBC_JOINT_STEREO_CHANNEL_MODE, - SBC_LOUDNESS_ALLOCATION_METHOD, make_audio_source_service_sdp_records, A2DP_SBC_CODEC_TYPE, SbcMediaCodecInformation, @@ -59,12 +57,12 @@ def codec_capabilities(): return MediaCodecCapabilities( media_type=AVDTP_AUDIO_MEDIA_TYPE, media_codec_type=A2DP_SBC_CODEC_TYPE, - media_codec_information=SbcMediaCodecInformation.from_discrete_values( - sampling_frequency=44100, - channel_mode=SBC_JOINT_STEREO_CHANNEL_MODE, - block_length=16, - subbands=8, - allocation_method=SBC_LOUDNESS_ALLOCATION_METHOD, + media_codec_information=SbcMediaCodecInformation( + sampling_frequency=SbcMediaCodecInformation.SamplingFrequency.SF_44100, + channel_mode=SbcMediaCodecInformation.ChannelMode.JOINT_STEREO, + block_length=SbcMediaCodecInformation.BlockLength.BL_16, + subbands=SbcMediaCodecInformation.Subbands.S_8, + allocation_method=SbcMediaCodecInformation.AllocationMethod.LOUDNESS, minimum_bitpool_value=2, maximum_bitpool_value=53, ), @@ -73,11 +71,9 @@ def codec_capabilities(): # ----------------------------------------------------------------------------- def on_avdtp_connection(read_function, protocol): - packet_source = SbcPacketSource( - read_function, protocol.l2cap_channel.peer_mtu, codec_capabilities() - ) + packet_source = SbcPacketSource(read_function, protocol.l2cap_channel.peer_mtu) packet_pump = MediaPacketPump(packet_source.packets) - protocol.add_source(packet_source.codec_capabilities, packet_pump) + protocol.add_source(codec_capabilities(), packet_pump) # ----------------------------------------------------------------------------- @@ -97,11 +93,9 @@ async def stream_packets(read_function, protocol): print(f'### Selected sink: {sink.seid}') # Stream the packets - packet_source = SbcPacketSource( - read_function, protocol.l2cap_channel.peer_mtu, codec_capabilities() - ) + packet_source = SbcPacketSource(read_function, protocol.l2cap_channel.peer_mtu) packet_pump = MediaPacketPump(packet_source.packets) - source = protocol.add_source(packet_source.codec_capabilities, packet_pump) + source = protocol.add_source(codec_capabilities(), packet_pump) stream = await protocol.create_stream(source, sink) await stream.start() await asyncio.sleep(5) diff --git a/examples/run_avrcp.py b/examples/run_avrcp.py index 793e0007..86361035 100644 --- a/examples/run_avrcp.py +++ b/examples/run_avrcp.py @@ -60,20 +60,23 @@ def codec_capabilities(): return avdtp.MediaCodecCapabilities( media_type=avdtp.AVDTP_AUDIO_MEDIA_TYPE, media_codec_type=a2dp.A2DP_SBC_CODEC_TYPE, - media_codec_information=a2dp.SbcMediaCodecInformation.from_lists( - sampling_frequencies=[48000, 44100, 32000, 16000], - channel_modes=[ - a2dp.SBC_MONO_CHANNEL_MODE, - a2dp.SBC_DUAL_CHANNEL_MODE, - a2dp.SBC_STEREO_CHANNEL_MODE, - a2dp.SBC_JOINT_STEREO_CHANNEL_MODE, - ], - block_lengths=[4, 8, 12, 16], - subbands=[4, 8], - allocation_methods=[ - a2dp.SBC_LOUDNESS_ALLOCATION_METHOD, - a2dp.SBC_SNR_ALLOCATION_METHOD, - ], + media_codec_information=a2dp.SbcMediaCodecInformation( + sampling_frequency=a2dp.SbcMediaCodecInformation.SamplingFrequency.SF_48000 + | a2dp.SbcMediaCodecInformation.SamplingFrequency.SF_44100 + | a2dp.SbcMediaCodecInformation.SamplingFrequency.SF_32000 + | a2dp.SbcMediaCodecInformation.SamplingFrequency.SF_16000, + channel_mode=a2dp.SbcMediaCodecInformation.ChannelMode.MONO + | a2dp.SbcMediaCodecInformation.ChannelMode.DUAL_CHANNEL + | a2dp.SbcMediaCodecInformation.ChannelMode.STEREO + | a2dp.SbcMediaCodecInformation.ChannelMode.JOINT_STEREO, + block_length=a2dp.SbcMediaCodecInformation.BlockLength.BL_4 + | a2dp.SbcMediaCodecInformation.BlockLength.BL_8 + | a2dp.SbcMediaCodecInformation.BlockLength.BL_12 + | a2dp.SbcMediaCodecInformation.BlockLength.BL_16, + subbands=a2dp.SbcMediaCodecInformation.Subbands.S_4 + | a2dp.SbcMediaCodecInformation.Subbands.S_8, + allocation_method=a2dp.SbcMediaCodecInformation.AllocationMethod.LOUDNESS + | a2dp.SbcMediaCodecInformation.AllocationMethod.SNR, minimum_bitpool_value=2, maximum_bitpool_value=53, ), diff --git a/setup.cfg b/setup.cfg index 305357a8..354bdd35 100644 --- a/setup.cfg +++ b/setup.cfg @@ -70,6 +70,7 @@ console_scripts = bumble-usb-probe = bumble.apps.usb_probe:main bumble-link-relay = bumble.apps.link_relay.link_relay:main bumble-bench = bumble.apps.bench:main + bumble-player = bumble.apps.player.player:main bumble-speaker = bumble.apps.speaker.speaker:main bumble-pandora-server = bumble.apps.pandora_server:main bumble-rtk-util = bumble.tools.rtk_util:main diff --git a/tests/a2dp_test.py b/tests/a2dp_test.py index ca598903..d28b4a43 100644 --- a/tests/a2dp_test.py +++ b/tests/a2dp_test.py @@ -33,20 +33,16 @@ Protocol, Listener, MediaCodecCapabilities, - MediaPacket, AVDTP_AUDIO_MEDIA_TYPE, AVDTP_TSEP_SNK, A2DP_SBC_CODEC_TYPE, ) from bumble.a2dp import ( + AacMediaCodecInformation, + OpusMediaCodecInformation, SbcMediaCodecInformation, - SBC_MONO_CHANNEL_MODE, - SBC_DUAL_CHANNEL_MODE, - SBC_STEREO_CHANNEL_MODE, - SBC_JOINT_STEREO_CHANNEL_MODE, - SBC_LOUDNESS_ALLOCATION_METHOD, - SBC_SNR_ALLOCATION_METHOD, ) +from bumble.rtp import MediaPacket # ----------------------------------------------------------------------------- # Logging @@ -125,12 +121,12 @@ def source_codec_capabilities(): return MediaCodecCapabilities( media_type=AVDTP_AUDIO_MEDIA_TYPE, media_codec_type=A2DP_SBC_CODEC_TYPE, - media_codec_information=SbcMediaCodecInformation.from_discrete_values( - sampling_frequency=44100, - channel_mode=SBC_JOINT_STEREO_CHANNEL_MODE, - block_length=16, - subbands=8, - allocation_method=SBC_LOUDNESS_ALLOCATION_METHOD, + media_codec_information=SbcMediaCodecInformation( + sampling_frequency=SbcMediaCodecInformation.SamplingFrequency.SF_44100, + channel_mode=SbcMediaCodecInformation.ChannelMode.JOINT_STEREO, + block_length=SbcMediaCodecInformation.BlockLength.BL_16, + subbands=SbcMediaCodecInformation.Subbands.S_8, + allocation_method=SbcMediaCodecInformation.AllocationMethod.LOUDNESS, minimum_bitpool_value=2, maximum_bitpool_value=53, ), @@ -142,20 +138,23 @@ def sink_codec_capabilities(): return MediaCodecCapabilities( media_type=AVDTP_AUDIO_MEDIA_TYPE, media_codec_type=A2DP_SBC_CODEC_TYPE, - media_codec_information=SbcMediaCodecInformation.from_lists( - sampling_frequencies=[48000, 44100, 32000, 16000], - channel_modes=[ - SBC_MONO_CHANNEL_MODE, - SBC_DUAL_CHANNEL_MODE, - SBC_STEREO_CHANNEL_MODE, - SBC_JOINT_STEREO_CHANNEL_MODE, - ], - block_lengths=[4, 8, 12, 16], - subbands=[4, 8], - allocation_methods=[ - SBC_LOUDNESS_ALLOCATION_METHOD, - SBC_SNR_ALLOCATION_METHOD, - ], + media_codec_information=SbcMediaCodecInformation( + sampling_frequency=SbcMediaCodecInformation.SamplingFrequency.SF_48000 + | SbcMediaCodecInformation.SamplingFrequency.SF_44100 + | SbcMediaCodecInformation.SamplingFrequency.SF_32000 + | SbcMediaCodecInformation.SamplingFrequency.SF_16000, + channel_mode=SbcMediaCodecInformation.ChannelMode.MONO + | SbcMediaCodecInformation.ChannelMode.DUAL_CHANNEL + | SbcMediaCodecInformation.ChannelMode.STEREO + | SbcMediaCodecInformation.ChannelMode.JOINT_STEREO, + block_length=SbcMediaCodecInformation.BlockLength.BL_4 + | SbcMediaCodecInformation.BlockLength.BL_8 + | SbcMediaCodecInformation.BlockLength.BL_12 + | SbcMediaCodecInformation.BlockLength.BL_16, + subbands=SbcMediaCodecInformation.Subbands.S_4 + | SbcMediaCodecInformation.Subbands.S_8, + allocation_method=SbcMediaCodecInformation.AllocationMethod.LOUDNESS + | SbcMediaCodecInformation.AllocationMethod.SNR, minimum_bitpool_value=2, maximum_bitpool_value=53, ), @@ -273,7 +272,125 @@ async def generate_packets(packet_count): # ----------------------------------------------------------------------------- -async def run_test_self(): +def test_sbc_codec_specific_information(): + sbc_info = SbcMediaCodecInformation.from_bytes(bytes.fromhex("3fff0235")) + assert ( + sbc_info.sampling_frequency + == SbcMediaCodecInformation.SamplingFrequency.SF_44100 + | SbcMediaCodecInformation.SamplingFrequency.SF_48000 + ) + assert ( + sbc_info.channel_mode + == SbcMediaCodecInformation.ChannelMode.MONO + | SbcMediaCodecInformation.ChannelMode.DUAL_CHANNEL + | SbcMediaCodecInformation.ChannelMode.STEREO + | SbcMediaCodecInformation.ChannelMode.JOINT_STEREO + ) + assert ( + sbc_info.block_length + == SbcMediaCodecInformation.BlockLength.BL_4 + | SbcMediaCodecInformation.BlockLength.BL_8 + | SbcMediaCodecInformation.BlockLength.BL_12 + | SbcMediaCodecInformation.BlockLength.BL_16 + ) + assert ( + sbc_info.subbands + == SbcMediaCodecInformation.Subbands.S_4 | SbcMediaCodecInformation.Subbands.S_8 + ) + assert ( + sbc_info.allocation_method + == SbcMediaCodecInformation.AllocationMethod.SNR + | SbcMediaCodecInformation.AllocationMethod.LOUDNESS + ) + assert sbc_info.minimum_bitpool_value == 2 + assert sbc_info.maximum_bitpool_value == 53 + + sbc_info2 = SbcMediaCodecInformation( + SbcMediaCodecInformation.SamplingFrequency.SF_44100 + | SbcMediaCodecInformation.SamplingFrequency.SF_48000, + SbcMediaCodecInformation.ChannelMode.MONO + | SbcMediaCodecInformation.ChannelMode.DUAL_CHANNEL + | SbcMediaCodecInformation.ChannelMode.STEREO + | SbcMediaCodecInformation.ChannelMode.JOINT_STEREO, + SbcMediaCodecInformation.BlockLength.BL_4 + | SbcMediaCodecInformation.BlockLength.BL_8 + | SbcMediaCodecInformation.BlockLength.BL_12 + | SbcMediaCodecInformation.BlockLength.BL_16, + SbcMediaCodecInformation.Subbands.S_4 | SbcMediaCodecInformation.Subbands.S_8, + SbcMediaCodecInformation.AllocationMethod.SNR + | SbcMediaCodecInformation.AllocationMethod.LOUDNESS, + 2, + 53, + ) + assert sbc_info == sbc_info2 + assert bytes(sbc_info2) == bytes.fromhex("3fff0235") + + +# ----------------------------------------------------------------------------- +def test_aac_codec_specific_information(): + aac_info = AacMediaCodecInformation.from_bytes(bytes.fromhex("f0018c83e800")) + assert ( + aac_info.object_type + == AacMediaCodecInformation.ObjectType.MPEG_2_AAC_LC + | AacMediaCodecInformation.ObjectType.MPEG_4_AAC_LC + | AacMediaCodecInformation.ObjectType.MPEG_4_AAC_LTP + | AacMediaCodecInformation.ObjectType.MPEG_4_AAC_SCALABLE + ) + assert ( + aac_info.sampling_frequency + == AacMediaCodecInformation.SamplingFrequency.SF_44100 + | AacMediaCodecInformation.SamplingFrequency.SF_48000 + ) + assert ( + aac_info.channels + == AacMediaCodecInformation.Channels.MONO + | AacMediaCodecInformation.Channels.STEREO + ) + assert aac_info.vbr == 1 + assert aac_info.bitrate == 256000 + + aac_info2 = AacMediaCodecInformation( + AacMediaCodecInformation.ObjectType.MPEG_2_AAC_LC + | AacMediaCodecInformation.ObjectType.MPEG_4_AAC_LC + | AacMediaCodecInformation.ObjectType.MPEG_4_AAC_LTP + | AacMediaCodecInformation.ObjectType.MPEG_4_AAC_SCALABLE, + AacMediaCodecInformation.SamplingFrequency.SF_44100 + | AacMediaCodecInformation.SamplingFrequency.SF_48000, + AacMediaCodecInformation.Channels.MONO + | AacMediaCodecInformation.Channels.STEREO, + 1, + 256000, + ) + assert aac_info == aac_info2 + assert bytes(aac_info2) == bytes.fromhex("f0018c83e800") + + +# ----------------------------------------------------------------------------- +def test_opus_codec_specific_information(): + opus_info = OpusMediaCodecInformation.from_bytes(bytes([0x92])) + assert opus_info.vendor_id == OpusMediaCodecInformation.VENDOR_ID + assert opus_info.codec_id == OpusMediaCodecInformation.CODEC_ID + assert opus_info.frame_size == OpusMediaCodecInformation.FrameSize.FS_20MS + assert opus_info.channel_mode == OpusMediaCodecInformation.ChannelMode.STEREO + assert ( + opus_info.sampling_frequency + == OpusMediaCodecInformation.SamplingFrequency.SF_48000 + ) + + opus_info2 = OpusMediaCodecInformation( + OpusMediaCodecInformation.ChannelMode.STEREO, + OpusMediaCodecInformation.FrameSize.FS_20MS, + OpusMediaCodecInformation.SamplingFrequency.SF_48000, + ) + assert opus_info2 == opus_info + assert opus_info2.value == bytes([0x92]) + + +# ----------------------------------------------------------------------------- +async def async_main(): + test_sbc_codec_specific_information() + test_aac_codec_specific_information() + test_opus_codec_specific_information() await test_self_connection() await test_source_sink_1() @@ -281,4 +398,4 @@ async def run_test_self(): # ----------------------------------------------------------------------------- if __name__ == '__main__': logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'INFO').upper()) - asyncio.run(run_test_self()) + asyncio.run(async_main()) diff --git a/tests/avdtp_test.py b/tests/avdtp_test.py index 666a84cf..09c4d866 100644 --- a/tests/avdtp_test.py +++ b/tests/avdtp_test.py @@ -23,13 +23,12 @@ AVDTP_MEDIA_TRANSPORT_SERVICE_CATEGORY, AVDTP_SET_CONFIGURATION, Message, - MediaPacket, Get_Capabilities_Response, Set_Configuration_Command, - Set_Configuration_Response, ServiceCapabilities, MediaCodecCapabilities, ) +from bumble.rtp import MediaPacket # ----------------------------------------------------------------------------- diff --git a/tests/codecs_test.py b/tests/codecs_test.py index b8affada..2a44e1e9 100644 --- a/tests/codecs_test.py +++ b/tests/codecs_test.py @@ -15,8 +15,9 @@ # ----------------------------------------------------------------------------- # Imports # ----------------------------------------------------------------------------- +import random import pytest -from bumble.codecs import AacAudioRtpPacket, BitReader +from bumble.codecs import AacAudioRtpPacket, BitReader, BitWriter # ----------------------------------------------------------------------------- @@ -49,19 +50,58 @@ def test_reader(): assert value == int.from_bytes(data, byteorder='big') +def test_writer(): + writer = BitWriter() + assert bytes(writer) == b'' + + for i in range(100): + for j in range(1, 10): + writer = BitWriter() + chunks = [] + for k in range(j): + n_bits = random.randint(1, 32) + random_bits = random.getrandbits(n_bits) + chunks.append((n_bits, random_bits)) + writer.write(random_bits, n_bits) + + written_data = bytes(writer) + reader = BitReader(written_data) + for n_bits, written_bits in chunks: + read_bits = reader.read(n_bits) + assert read_bits == written_bits + + def test_aac_rtp(): # pylint: disable=line-too-long packet_data = bytes.fromhex( '47fc0000b090800300202066000198000de120000000000000000000000000000000000000000000001c' ) - packet = AacAudioRtpPacket(packet_data) + packet = AacAudioRtpPacket.from_bytes(packet_data) adts = packet.to_adts() assert adts == bytes.fromhex( 'fff1508004fffc2066000198000de120000000000000000000000000000000000000000000001c' ) + payload = bytes(list(range(1, 200))) + rtp = AacAudioRtpPacket.for_simple_aac(44100, 2, payload) + assert rtp.audio_mux_element.payload == payload + assert ( + rtp.audio_mux_element.stream_mux_config.audio_specific_config.sampling_frequency + == 44100 + ) + assert ( + rtp.audio_mux_element.stream_mux_config.audio_specific_config.channel_configuration + == 2 + ) + rtp2 = AacAudioRtpPacket.from_bytes(bytes(rtp)) + assert str(rtp2.audio_mux_element.stream_mux_config) == str( + rtp.audio_mux_element.stream_mux_config + ) + assert rtp2.audio_mux_element.payload == rtp.audio_mux_element.payload + # ----------------------------------------------------------------------------- if __name__ == '__main__': test_reader() + test_writer() test_aac_rtp() diff --git a/web/speaker/speaker.py b/web/speaker/speaker.py index 2b8ce006..51875812 100644 --- a/web/speaker/speaker.py +++ b/web/speaker/speaker.py @@ -28,26 +28,18 @@ AVDTP_AUDIO_MEDIA_TYPE, Listener, MediaCodecCapabilities, - MediaPacket, Protocol, ) from bumble.a2dp import ( make_audio_sink_service_sdp_records, - MPEG_2_AAC_LC_OBJECT_TYPE, A2DP_SBC_CODEC_TYPE, A2DP_MPEG_2_4_AAC_CODEC_TYPE, - SBC_MONO_CHANNEL_MODE, - SBC_DUAL_CHANNEL_MODE, - SBC_SNR_ALLOCATION_METHOD, - SBC_LOUDNESS_ALLOCATION_METHOD, - SBC_STEREO_CHANNEL_MODE, - SBC_JOINT_STEREO_CHANNEL_MODE, SbcMediaCodecInformation, AacMediaCodecInformation, ) -from bumble.utils import AsyncRunner from bumble.codecs import AacAudioRtpPacket from bumble.hci import HCI_Reset_Command +from bumble.rtp import MediaPacket # ----------------------------------------------------------------------------- @@ -72,7 +64,7 @@ def extract_audio(self, packet: MediaPacket) -> bytes: # ----------------------------------------------------------------------------- class AacAudioExtractor: def extract_audio(self, packet: MediaPacket) -> bytes: - return AacAudioRtpPacket(packet.payload).to_adts() + return AacAudioRtpPacket.from_bytes(packet.payload).to_adts() # ----------------------------------------------------------------------------- @@ -130,10 +122,12 @@ def aac_codec_capabilities(self) -> MediaCodecCapabilities: return MediaCodecCapabilities( media_type=AVDTP_AUDIO_MEDIA_TYPE, media_codec_type=A2DP_MPEG_2_4_AAC_CODEC_TYPE, - media_codec_information=AacMediaCodecInformation.from_lists( - object_types=[MPEG_2_AAC_LC_OBJECT_TYPE], - sampling_frequencies=[48000, 44100], - channels=[1, 2], + media_codec_information=AacMediaCodecInformation( + object_type=AacMediaCodecInformation.ObjectType.MPEG_2_AAC_LC, + sampling_frequency=AacMediaCodecInformation.SamplingFrequency.SF_48000 + | AacMediaCodecInformation.SamplingFrequency.SF_44100, + channels=AacMediaCodecInformation.Channels.MONO + | AacMediaCodecInformation.Channels.STEREO, vbr=1, bitrate=256000, ), @@ -143,20 +137,23 @@ def sbc_codec_capabilities(self) -> MediaCodecCapabilities: return MediaCodecCapabilities( media_type=AVDTP_AUDIO_MEDIA_TYPE, media_codec_type=A2DP_SBC_CODEC_TYPE, - media_codec_information=SbcMediaCodecInformation.from_lists( - sampling_frequencies=[48000, 44100, 32000, 16000], - channel_modes=[ - SBC_MONO_CHANNEL_MODE, - SBC_DUAL_CHANNEL_MODE, - SBC_STEREO_CHANNEL_MODE, - SBC_JOINT_STEREO_CHANNEL_MODE, - ], - block_lengths=[4, 8, 12, 16], - subbands=[4, 8], - allocation_methods=[ - SBC_LOUDNESS_ALLOCATION_METHOD, - SBC_SNR_ALLOCATION_METHOD, - ], + media_codec_information=SbcMediaCodecInformation( + sampling_frequency=SbcMediaCodecInformation.SamplingFrequency.SF_48000 + | SbcMediaCodecInformation.SamplingFrequency.SF_44100 + | SbcMediaCodecInformation.SamplingFrequency.SF_32000 + | SbcMediaCodecInformation.SamplingFrequency.SF_16000, + channel_mode=SbcMediaCodecInformation.ChannelMode.MONO + | SbcMediaCodecInformation.ChannelMode.DUAL_CHANNEL + | SbcMediaCodecInformation.ChannelMode.STEREO + | SbcMediaCodecInformation.ChannelMode.JOINT_STEREO, + block_length=SbcMediaCodecInformation.BlockLength.BL_4 + | SbcMediaCodecInformation.BlockLength.BL_8 + | SbcMediaCodecInformation.BlockLength.BL_12 + | SbcMediaCodecInformation.BlockLength.BL_16, + subbands=SbcMediaCodecInformation.Subbands.S_4 + | SbcMediaCodecInformation.Subbands.S_8, + allocation_method=SbcMediaCodecInformation.AllocationMethod.LOUDNESS + | SbcMediaCodecInformation.AllocationMethod.SNR, minimum_bitpool_value=2, maximum_bitpool_value=53, ), @@ -282,9 +279,6 @@ async def run(self, connect_address): mitm=False ) - # Start the controller - await self.device.power_on() - # Listen for Bluetooth connections self.device.on('connection', self.on_bluetooth_connection) @@ -295,6 +289,9 @@ async def run(self, connect_address): self.avdtp_listener = Listener.for_device(self.device) self.avdtp_listener.on('connection', self.on_avdtp_connection) + # Start the controller + await self.device.power_on() + print(f'Speaker ready to play, codec={self.codec}') if connect_address: