Merge pull request #571 from google/gbg/a2dp-player

a2dp player
This commit is contained in:
Gilles Boccon-Gibod
2024-10-19 07:40:43 -07:00
committed by GitHub
19 changed files with 1884 additions and 547 deletions

608
apps/player/player.py Normal file
View File

@@ -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

View File

@@ -44,25 +44,18 @@ from bumble.avdtp import (
AVDTP_AUDIO_MEDIA_TYPE, AVDTP_AUDIO_MEDIA_TYPE,
Listener, Listener,
MediaCodecCapabilities, MediaCodecCapabilities,
MediaPacket,
Protocol, Protocol,
) )
from bumble.a2dp import ( from bumble.a2dp import (
MPEG_2_AAC_LC_OBJECT_TYPE,
make_audio_sink_service_sdp_records, make_audio_sink_service_sdp_records,
A2DP_SBC_CODEC_TYPE, A2DP_SBC_CODEC_TYPE,
A2DP_MPEG_2_4_AAC_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, SbcMediaCodecInformation,
AacMediaCodecInformation, AacMediaCodecInformation,
) )
from bumble.utils import AsyncRunner from bumble.utils import AsyncRunner
from bumble.codecs import AacAudioRtpPacket from bumble.codecs import AacAudioRtpPacket
from bumble.rtp import MediaPacket
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@@ -93,7 +86,7 @@ class AudioExtractor:
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
class AacAudioExtractor: class AacAudioExtractor:
def extract_audio(self, packet: MediaPacket) -> bytes: 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 @@ class Speaker:
return MediaCodecCapabilities( return MediaCodecCapabilities(
media_type=AVDTP_AUDIO_MEDIA_TYPE, media_type=AVDTP_AUDIO_MEDIA_TYPE,
media_codec_type=A2DP_MPEG_2_4_AAC_CODEC_TYPE, media_codec_type=A2DP_MPEG_2_4_AAC_CODEC_TYPE,
media_codec_information=AacMediaCodecInformation.from_lists( media_codec_information=AacMediaCodecInformation(
object_types=[MPEG_2_AAC_LC_OBJECT_TYPE], object_type=AacMediaCodecInformation.ObjectType.MPEG_2_AAC_LC,
sampling_frequencies=[48000, 44100], sampling_frequency=AacMediaCodecInformation.SamplingFrequency.SF_48000
channels=[1, 2], | AacMediaCodecInformation.SamplingFrequency.SF_44100,
channels=AacMediaCodecInformation.Channels.MONO
| AacMediaCodecInformation.Channels.STEREO,
vbr=1, vbr=1,
bitrate=256000, bitrate=256000,
), ),
@@ -464,20 +459,23 @@ class Speaker:
return MediaCodecCapabilities( return MediaCodecCapabilities(
media_type=AVDTP_AUDIO_MEDIA_TYPE, media_type=AVDTP_AUDIO_MEDIA_TYPE,
media_codec_type=A2DP_SBC_CODEC_TYPE, media_codec_type=A2DP_SBC_CODEC_TYPE,
media_codec_information=SbcMediaCodecInformation.from_lists( media_codec_information=SbcMediaCodecInformation(
sampling_frequencies=[48000, 44100, 32000, 16000], sampling_frequency=SbcMediaCodecInformation.SamplingFrequency.SF_48000
channel_modes=[ | SbcMediaCodecInformation.SamplingFrequency.SF_44100
SBC_MONO_CHANNEL_MODE, | SbcMediaCodecInformation.SamplingFrequency.SF_32000
SBC_DUAL_CHANNEL_MODE, | SbcMediaCodecInformation.SamplingFrequency.SF_16000,
SBC_STEREO_CHANNEL_MODE, channel_mode=SbcMediaCodecInformation.ChannelMode.MONO
SBC_JOINT_STEREO_CHANNEL_MODE, | SbcMediaCodecInformation.ChannelMode.DUAL_CHANNEL
], | SbcMediaCodecInformation.ChannelMode.STEREO
block_lengths=[4, 8, 12, 16], | SbcMediaCodecInformation.ChannelMode.JOINT_STEREO,
subbands=[4, 8], block_length=SbcMediaCodecInformation.BlockLength.BL_4
allocation_methods=[ | SbcMediaCodecInformation.BlockLength.BL_8
SBC_LOUDNESS_ALLOCATION_METHOD, | SbcMediaCodecInformation.BlockLength.BL_12
SBC_SNR_ALLOCATION_METHOD, | 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, minimum_bitpool_value=2,
maximum_bitpool_value=53, maximum_bitpool_value=53,
), ),

View File

@@ -17,12 +17,16 @@
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
from __future__ import annotations from __future__ import annotations
import dataclasses
import struct
import logging
from collections.abc import AsyncGenerator from collections.abc import AsyncGenerator
from typing import List, Callable, Awaitable import dataclasses
import enum
import logging
import struct
from typing import Awaitable, Callable
from typing_extensions import ClassVar, Self
from .codecs import AacAudioRtpPacket
from .company_ids import COMPANY_IDENTIFIERS from .company_ids import COMPANY_IDENTIFIERS
from .sdp import ( from .sdp import (
DataElement, DataElement,
@@ -42,6 +46,7 @@ from .core import (
BT_ADVANCED_AUDIO_DISTRIBUTION_SERVICE, BT_ADVANCED_AUDIO_DISTRIBUTION_SERVICE,
name_or_number, name_or_number,
) )
from .rtp import MediaPacket
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@@ -103,6 +108,8 @@ SBC_ALLOCATION_METHOD_NAMES = {
SBC_LOUDNESS_ALLOCATION_METHOD: 'SBC_LOUDNESS_ALLOCATION_METHOD' SBC_LOUDNESS_ALLOCATION_METHOD: 'SBC_LOUDNESS_ALLOCATION_METHOD'
} }
SBC_MAX_FRAMES_IN_RTP_PAYLOAD = 15
MPEG_2_4_AAC_SAMPLING_FREQUENCIES = [ MPEG_2_4_AAC_SAMPLING_FREQUENCIES = [
8000, 8000,
11025, 11025,
@@ -130,6 +137,9 @@ MPEG_2_4_OBJECT_TYPE_NAMES = {
MPEG_4_AAC_SCALABLE_OBJECT_TYPE: 'MPEG_4_AAC_SCALABLE_OBJECT_TYPE' MPEG_4_AAC_SCALABLE_OBJECT_TYPE: 'MPEG_4_AAC_SCALABLE_OBJECT_TYPE'
} }
OPUS_MAX_FRAMES_IN_RTP_PAYLOAD = 15
# fmt: on # fmt: on
@@ -257,38 +267,61 @@ class SbcMediaCodecInformation:
A2DP spec - 4.3.2 Codec Specific Information Elements A2DP spec - 4.3.2 Codec Specific Information Elements
''' '''
sampling_frequency: int sampling_frequency: SamplingFrequency
channel_mode: int channel_mode: ChannelMode
block_length: int block_length: BlockLength
subbands: int subbands: Subbands
allocation_method: int allocation_method: AllocationMethod
minimum_bitpool_value: int minimum_bitpool_value: int
maximum_bitpool_value: int maximum_bitpool_value: int
SAMPLING_FREQUENCY_BITS = {16000: 1 << 3, 32000: 1 << 2, 44100: 1 << 1, 48000: 1} class SamplingFrequency(enum.IntFlag):
CHANNEL_MODE_BITS = { SF_16000 = 1 << 3
SBC_MONO_CHANNEL_MODE: 1 << 3, SF_32000 = 1 << 2
SBC_DUAL_CHANNEL_MODE: 1 << 2, SF_44100 = 1 << 1
SBC_STEREO_CHANNEL_MODE: 1 << 1, SF_48000 = 1 << 0
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,
}
@staticmethod @classmethod
def from_bytes(data: bytes) -> SbcMediaCodecInformation: def from_int(cls, sampling_frequency: int) -> Self:
sampling_frequency = (data[0] >> 4) & 0x0F sampling_frequencies = [
channel_mode = (data[0] >> 0) & 0x0F 16000,
block_length = (data[1] >> 4) & 0x0F 32000,
subbands = (data[1] >> 2) & 0x03 44100,
allocation_method = (data[1] >> 0) & 0x03 48000,
]
index = sampling_frequencies.index(sampling_frequency)
return cls(1 << (len(sampling_frequencies) - index - 1))
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 minimum_bitpool_value = (data[2] >> 0) & 0xFF
maximum_bitpool_value = (data[3] >> 0) & 0xFF maximum_bitpool_value = (data[3] >> 0) & 0xFF
return SbcMediaCodecInformation( return cls(
sampling_frequency, sampling_frequency,
channel_mode, channel_mode,
block_length, block_length,
@@ -298,52 +331,6 @@ class SbcMediaCodecInformation:
maximum_bitpool_value, 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: def __bytes__(self) -> bytes:
return bytes( return bytes(
[ [
@@ -356,23 +343,6 @@ class SbcMediaCodecInformation:
] ]
) )
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 @dataclasses.dataclass
@@ -381,83 +351,66 @@ class AacMediaCodecInformation:
A2DP spec - 4.5.2 Codec Specific Information Elements A2DP spec - 4.5.2 Codec Specific Information Elements
''' '''
object_type: int object_type: ObjectType
sampling_frequency: int sampling_frequency: SamplingFrequency
channels: int channels: Channels
rfa: int
vbr: int vbr: int
bitrate: int bitrate: int
OBJECT_TYPE_BITS = { class ObjectType(enum.IntFlag):
MPEG_2_AAC_LC_OBJECT_TYPE: 1 << 7, MPEG_2_AAC_LC = 1 << 7
MPEG_4_AAC_LC_OBJECT_TYPE: 1 << 6, MPEG_4_AAC_LC = 1 << 6
MPEG_4_AAC_LTP_OBJECT_TYPE: 1 << 5, MPEG_4_AAC_LTP = 1 << 5
MPEG_4_AAC_SCALABLE_OBJECT_TYPE: 1 << 4, MPEG_4_AAC_SCALABLE = 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}
@staticmethod class SamplingFrequency(enum.IntFlag):
def from_bytes(data: bytes) -> AacMediaCodecInformation: SF_8000 = 1 << 11
object_type = data[0] SF_11025 = 1 << 10
sampling_frequency = (data[1] << 4) | ((data[2] >> 4) & 0x0F) SF_12000 = 1 << 9
channels = (data[2] >> 2) & 0x03 SF_16000 = 1 << 8
rfa = 0 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))
class Channels(enum.IntFlag):
MONO = 1 << 1
STEREO = 1 << 0
@classmethod
def from_bytes(cls, data: bytes) -> AacMediaCodecInformation:
object_type = cls.ObjectType(data[0])
sampling_frequency = cls.SamplingFrequency(
(data[1] << 4) | ((data[2] >> 4) & 0x0F)
)
channels = cls.Channels((data[2] >> 2) & 0x03)
vbr = (data[3] >> 7) & 0x01 vbr = (data[3] >> 7) & 0x01
bitrate = ((data[3] & 0x7F) << 16) | (data[4] << 8) | data[5] bitrate = ((data[3] & 0x7F) << 16) | (data[4] << 8) | data[5]
return AacMediaCodecInformation( return AacMediaCodecInformation(
object_type, sampling_frequency, channels, rfa, vbr, bitrate object_type, sampling_frequency, channels, vbr, bitrate
)
@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,
)
@classmethod
def from_lists(
cls,
object_types: List[int],
sampling_frequencies: List[int],
channels: List[int],
vbr: int,
bitrate: int,
) -> AacMediaCodecInformation:
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,
) )
def __bytes__(self) -> bytes: def __bytes__(self) -> bytes:
@@ -472,30 +425,6 @@ class AacMediaCodecInformation:
] ]
) )
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 @dataclasses.dataclass
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@@ -514,7 +443,7 @@ class VendorSpecificMediaCodecInformation:
return VendorSpecificMediaCodecInformation(vendor_id, codec_id, data[6:]) return VendorSpecificMediaCodecInformation(vendor_id, codec_id, data[6:])
def __bytes__(self) -> bytes: def __bytes__(self) -> bytes:
return struct.pack('<IH', self.vendor_id, self.codec_id, self.value) return struct.pack('<IH', self.vendor_id, self.codec_id) + self.value
def __str__(self) -> str: def __str__(self) -> str:
# pylint: disable=line-too-long # pylint: disable=line-too-long
@@ -528,13 +457,69 @@ class VendorSpecificMediaCodecInformation:
) )
# -----------------------------------------------------------------------------
@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 @dataclasses.dataclass
class SbcFrame: class SbcFrame:
sampling_frequency: int sampling_frequency: int
block_count: int block_count: int
channel_mode: int channel_mode: int
allocation_method: int
subband_count: int subband_count: int
bitpool: int
payload: bytes payload: bytes
@property @property
@@ -553,8 +538,10 @@ class SbcFrame:
return ( return (
f'SBC(sf={self.sampling_frequency},' f'SBC(sf={self.sampling_frequency},'
f'cm={self.channel_mode},' f'cm={self.channel_mode},'
f'am={self.allocation_method},'
f'br={self.bitrate},' f'br={self.bitrate},'
f'sc={self.sample_count},' f'sc={self.sample_count},'
f'bp={self.bitpool},'
f'size={len(self.payload)})' f'size={len(self.payload)})'
) )
@@ -583,6 +570,7 @@ class SbcParser:
blocks = 4 * (1 + ((header[1] >> 4) & 3)) blocks = 4 * (1 + ((header[1] >> 4) & 3))
channel_mode = (header[1] >> 2) & 3 channel_mode = (header[1] >> 2) & 3
channels = 1 if channel_mode == SBC_MONO_CHANNEL_MODE else 2 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 subbands = 8 if ((header[1]) & 1) else 4
bitpool = header[2] bitpool = header[2]
@@ -602,7 +590,13 @@ class SbcParser:
# Emit the next frame # Emit the next frame
yield SbcFrame( yield SbcFrame(
sampling_frequency, blocks, channel_mode, subbands, payload sampling_frequency,
blocks,
channel_mode,
allocation_method,
subbands,
bitpool,
payload,
) )
return generate_frames() return generate_frames()
@@ -610,21 +604,15 @@ class SbcParser:
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
class SbcPacketSource: class SbcPacketSource:
def __init__( def __init__(self, read: Callable[[int], Awaitable[bytes]], mtu: int) -> None:
self, read: Callable[[int], Awaitable[bytes]], mtu: int, codec_capabilities
) -> None:
self.read = read self.read = read
self.mtu = mtu self.mtu = mtu
self.codec_capabilities = codec_capabilities
@property @property
def packets(self): def packets(self):
async def generate_packets(): async def generate_packets():
# pylint: disable=import-outside-toplevel
from .avdtp import MediaPacket # Import here to avoid a circular reference
sequence_number = 0 sequence_number = 0
timestamp = 0 sample_count = 0
frames = [] frames = []
frames_size = 0 frames_size = 0
max_rtp_payload = self.mtu - 12 - 1 max_rtp_payload = self.mtu - 12 - 1
@@ -632,29 +620,29 @@ class SbcPacketSource:
# NOTE: this doesn't support frame fragments # NOTE: this doesn't support frame fragments
sbc_parser = SbcParser(self.read) sbc_parser = SbcParser(self.read)
async for frame in sbc_parser.frames: async for frame in sbc_parser.frames:
print(frame)
if ( if (
frames_size + len(frame.payload) > max_rtp_payload 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 # Need to flush what has been accumulated so far
logger.debug(f"yielding {len(frames)} frames")
# Emit a packet # Emit a packet
sbc_payload = bytes([len(frames)]) + b''.join( sbc_payload = bytes([len(frames) & 0x0F]) + b''.join(
[frame.payload for frame in frames] [frame.payload for frame in frames]
) )
timestamp_seconds = sample_count / frame.sampling_frequency
timestamp = int(1000 * timestamp_seconds)
packet = MediaPacket( packet = MediaPacket(
2, 0, 0, 0, sequence_number, timestamp, 0, [], 96, sbc_payload 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 yield packet
# Prepare for next packets # Prepare for next packets
sequence_number += 1 sequence_number += 1
sequence_number &= 0xFFFF sequence_number &= 0xFFFF
timestamp += sum((frame.sample_count for frame in frames)) sample_count += sum((frame.sample_count for frame in frames))
timestamp &= 0xFFFFFFFF
frames = [frame] frames = [frame]
frames_size = len(frame.payload) frames_size = len(frame.payload)
else: else:
@@ -663,3 +651,315 @@ class SbcPacketSource:
frames_size += len(frame.payload) frames_size += len(frame.payload)
return generate_packets() 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("<QIIIB", header, 6)
segment_table = await self.read(page_segments)
if header_type & self.HeaderType.FIRST:
if expected_bitstream_serial_number is None:
# We will only accept pages for the first encountered stream
logger.debug("BOS")
expected_bitstream_serial_number = bitstream_serial_number
expected_page_sequence_number = page_sequence_number
if (
expected_bitstream_serial_number is None
or expected_bitstream_serial_number != bitstream_serial_number
):
logger.debug("skipping page (not the first logical bitstream)")
for lacing_value in segment_table:
if lacing_value:
await self.read(lacing_value)
continue
if expected_page_sequence_number != page_sequence_number:
raise ValueError(
f"expected page sequence number {expected_page_sequence_number}"
f" but got {page_sequence_number}"
)
expected_page_sequence_number = page_sequence_number + 1
# Assemble the page
if not header_type & self.HeaderType.CONTINUED:
packet = b''
for lacing_value in segment_table:
if lacing_value:
packet += await self.read(lacing_value)
if lacing_value < 255:
# End of packet
packet_count += 1
if packet_count == 1:
# The first packet contains the identification header
logger.debug("first packet (header)")
if packet[:8] != b"OpusHead":
raise ValueError("first packet is not OpusHead")
packet_count = (
OpusPacket.ChannelMode.MONO
if packet[9] == 1
else OpusPacket.ChannelMode.STEREO
)
elif packet_count == 2:
# The second packet contains the comment header
logger.debug("second packet (tags)")
if packet[:8] != b"OpusTags":
logger.warning("second packet is not OpusTags")
else:
yield OpusPacket(channel_mode, 20, 48000, packet)
packet = b''
if header_type & self.HeaderType.LAST:
logger.debug("EOS")
return generate_frames()
# -----------------------------------------------------------------------------
class OpusPacketSource:
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
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
}
}

View File

@@ -119,7 +119,7 @@ class Frame:
# Not supported # Not supported
raise NotImplementedError("extended subunit types not supported") raise NotImplementedError("extended subunit types not supported")
if subunit_id < 5: if subunit_id < 5 or subunit_id == 7:
opcode_offset = 2 opcode_offset = 2
elif subunit_id == 5: elif subunit_id == 5:
# Extended to the next byte # Extended to the next byte
@@ -132,7 +132,6 @@ class Frame:
else: else:
subunit_id = 5 + extension subunit_id = 5 + extension
opcode_offset = 3 opcode_offset = 3
elif subunit_id == 6: elif subunit_id == 6:
raise core.InvalidPacketError("reserved subunit ID") raise core.InvalidPacketError("reserved subunit ID")

View File

@@ -17,12 +17,10 @@
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import struct
import time import time
import logging import logging
import enum import enum
import warnings import warnings
from pyee import EventEmitter
from typing import ( from typing import (
Any, Any,
Awaitable, Awaitable,
@@ -39,6 +37,8 @@ from typing import (
cast, cast,
) )
from pyee import EventEmitter
from .core import ( from .core import (
BT_ADVANCED_AUDIO_DISTRIBUTION_SERVICE, BT_ADVANCED_AUDIO_DISTRIBUTION_SERVICE,
InvalidStateError, InvalidStateError,
@@ -51,13 +51,16 @@ from .a2dp import (
A2DP_MPEG_2_4_AAC_CODEC_TYPE, A2DP_MPEG_2_4_AAC_CODEC_TYPE,
A2DP_NON_A2DP_CODEC_TYPE, A2DP_NON_A2DP_CODEC_TYPE,
A2DP_SBC_CODEC_TYPE, A2DP_SBC_CODEC_TYPE,
A2DP_VENDOR_MEDIA_CODEC_INFORMATION_CLASSES,
AacMediaCodecInformation, AacMediaCodecInformation,
SbcMediaCodecInformation, SbcMediaCodecInformation,
VendorSpecificMediaCodecInformation, VendorSpecificMediaCodecInformation,
) )
from .rtp import MediaPacket
from . import sdp, device, l2cap from . import sdp, device, l2cap
from .colors import color from .colors import color
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Logging # Logging
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@@ -278,95 +281,6 @@ class RealtimeClock:
await asyncio.sleep(duration) 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: class MediaPacketPump:
pump_task: Optional[asyncio.Task] pump_task: Optional[asyncio.Task]
@@ -377,6 +291,7 @@ class MediaPacketPump:
self.packets = packets self.packets = packets
self.clock = clock self.clock = clock
self.pump_task = None self.pump_task = None
self.completed = asyncio.Event()
async def start(self, rtp_channel: l2cap.ClassicChannel) -> None: async def start(self, rtp_channel: l2cap.ClassicChannel) -> None:
async def pump_packets(): async def pump_packets():
@@ -406,6 +321,8 @@ class MediaPacketPump:
) )
except asyncio.exceptions.CancelledError: except asyncio.exceptions.CancelledError:
logger.debug('pump canceled') logger.debug('pump canceled')
finally:
self.completed.set()
# Pump packets # Pump packets
self.pump_task = asyncio.create_task(pump_packets()) self.pump_task = asyncio.create_task(pump_packets())
@@ -417,6 +334,9 @@ class MediaPacketPump:
await self.pump_task await self.pump_task
self.pump_task = None self.pump_task = None
async def wait_for_completion(self) -> None:
await self.completed.wait()
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
class MessageAssembler: class MessageAssembler:
@@ -615,11 +535,25 @@ class MediaCodecCapabilities(ServiceCapabilities):
self.media_codec_information self.media_codec_information
) )
elif self.media_codec_type == A2DP_NON_A2DP_CODEC_TYPE: elif self.media_codec_type == A2DP_NON_A2DP_CODEC_TYPE:
self.media_codec_information = ( vendor_media_codec_information = (
VendorSpecificMediaCodecInformation.from_bytes( VendorSpecificMediaCodecInformation.from_bytes(
self.media_codec_information 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__( def __init__(
self, self,
@@ -1316,10 +1250,20 @@ class Protocol(EventEmitter):
return None return None
def add_source( def add_source(
self, codec_capabilities: MediaCodecCapabilities, packet_pump: MediaPacketPump self,
codec_capabilities: MediaCodecCapabilities,
packet_pump: MediaPacketPump,
delay_reporting: bool = False,
) -> LocalSource: ) -> LocalSource:
seid = len(self.local_endpoints) + 1 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) self.local_endpoints.append(source)
return source return source
@@ -1372,7 +1316,7 @@ class Protocol(EventEmitter):
return self.remote_endpoints.values() return self.remote_endpoints.values()
def find_remote_sink_by_codec( 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]: ) -> Optional[DiscoveredStreamEndPoint]:
for endpoint in self.remote_endpoints.values(): for endpoint in self.remote_endpoints.values():
if ( if (
@@ -1397,7 +1341,19 @@ class Protocol(EventEmitter):
codec_capabilities.media_type == AVDTP_AUDIO_MEDIA_TYPE codec_capabilities.media_type == AVDTP_AUDIO_MEDIA_TYPE
and codec_capabilities.media_codec_type == codec_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: if has_media_transport and has_codec:
return endpoint return endpoint
@@ -2180,12 +2136,13 @@ class LocalSource(LocalStreamEndPoint):
protocol: Protocol, protocol: Protocol,
seid: int, seid: int,
codec_capabilities: MediaCodecCapabilities, codec_capabilities: MediaCodecCapabilities,
other_capabilitiles: Iterable[ServiceCapabilities],
packet_pump: MediaPacketPump, packet_pump: MediaPacketPump,
) -> None: ) -> None:
capabilities = [ capabilities = [
ServiceCapabilities(AVDTP_MEDIA_TRANSPORT_SERVICE_CATEGORY), ServiceCapabilities(AVDTP_MEDIA_TRANSPORT_SERVICE_CATEGORY),
codec_capabilities, codec_capabilities,
] ] + list(other_capabilitiles)
super().__init__( super().__init__(
protocol, protocol,
seid, seid,

View File

@@ -1491,10 +1491,14 @@ class Protocol(pyee.EventEmitter):
f"<<< AVCTP Command, transaction_label={transaction_label}: " f"{command}" f"<<< AVCTP Command, transaction_label={transaction_label}: " f"{command}"
) )
# Only the PANEL subunit type with subunit ID 0 is supported in this profile. # Only addressing the unit, or the PANEL subunit with subunit ID 0 is supported
if ( # in this profile.
command.subunit_type != avc.Frame.SubunitType.PANEL if not (
or command.subunit_id != 0 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") logger.debug("subunit not supported")
self.send_not_implemented_response(transaction_label, command) self.send_not_implemented_response(transaction_label, command)
@@ -1528,8 +1532,8 @@ class Protocol(pyee.EventEmitter):
# TODO: delegate # TODO: delegate
response = avc.PassThroughResponseFrame( response = avc.PassThroughResponseFrame(
avc.ResponseFrame.ResponseCode.ACCEPTED, avc.ResponseFrame.ResponseCode.ACCEPTED,
avc.Frame.SubunitType.PANEL, command.subunit_type,
0, command.subunit_id,
command.state_flag, command.state_flag,
command.operation_id, command.operation_id,
command.operation_data, command.operation_data,
@@ -1846,6 +1850,15 @@ class Protocol(pyee.EventEmitter):
RejectedResponse(pdu_id, status_code), 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( def _on_get_capabilities_command(
self, transaction_label: int, command: GetCapabilitiesCommand self, transaction_label: int, command: GetCapabilitiesCommand
) -> None: ) -> None:
@@ -1891,29 +1904,35 @@ class Protocol(pyee.EventEmitter):
async def register_notification(): async def register_notification():
# Check if the event is supported. # Check if the event is supported.
supported_events = await self.delegate.get_supported_events() supported_events = await self.delegate.get_supported_events()
if command.event_id in supported_events: if command.event_id not in supported_events:
if command.event_id == EventId.VOLUME_CHANGED: logger.debug("event not supported")
volume = await self.delegate.get_absolute_volume() self.send_not_implemented_avrcp_response(
response = RegisterNotificationResponse(VolumeChangedEvent(volume)) transaction_label, self.PduId.REGISTER_NOTIFICATION
self.send_avrcp_response( )
transaction_label, return
avc.ResponseFrame.ResponseCode.INTERIM,
response,
)
self._register_notification_listener(transaction_label, command)
return
if command.event_id == EventId.PLAYBACK_STATUS_CHANGED: if command.event_id == EventId.VOLUME_CHANGED:
# TODO: testing only, use delegate volume = await self.delegate.get_absolute_volume()
response = RegisterNotificationResponse( response = RegisterNotificationResponse(VolumeChangedEvent(volume))
PlaybackStatusChangedEvent(play_status=PlayStatus.PLAYING) self.send_avrcp_response(
) transaction_label,
self.send_avrcp_response( avc.ResponseFrame.ResponseCode.INTERIM,
transaction_label, response,
avc.ResponseFrame.ResponseCode.INTERIM, )
response, self._register_notification_listener(transaction_label, command)
) return
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()) self._delegate_command(transaction_label, command, register_notification())

View File

@@ -17,6 +17,7 @@
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from typing_extensions import Self
from bumble import core from bumble import core
@@ -101,12 +102,40 @@ class BitReader:
break 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: class AacAudioRtpPacket:
"""AAC payload encapsulated in an RTP packet payload""" """AAC payload encapsulated in an RTP packet payload"""
audio_mux_element: AudioMuxElement
@staticmethod @staticmethod
def latm_value(reader: BitReader) -> int: def read_latm_value(reader: BitReader) -> int:
bytes_for_value = reader.read(2) bytes_for_value = reader.read(2)
value = 0 value = 0
for _ in range(bytes_for_value + 1): for _ in range(bytes_for_value + 1):
@@ -114,24 +143,33 @@ class AacAudioRtpPacket:
return value return value
@staticmethod @staticmethod
def program_config_element(reader: BitReader): def read_audio_object_type(reader: BitReader):
raise core.InvalidPacketError('program_config_element not supported') # 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 @dataclass
class GASpecificConfig: class GASpecificConfig:
def __init__( audio_object_type: int
self, reader: BitReader, channel_configuration: int, audio_object_type: int # NOTE: other fields not supported
) -> None:
@classmethod
def from_bits(
cls, reader: BitReader, channel_configuration: int, audio_object_type: int
) -> Self:
# GASpecificConfig - ISO/EIC 14496-3 Table 4.1 # GASpecificConfig - ISO/EIC 14496-3 Table 4.1
frame_length_flag = reader.read(1) frame_length_flag = reader.read(1)
depends_on_core_coder = reader.read(1) depends_on_core_coder = reader.read(1)
if depends_on_core_coder: if depends_on_core_coder:
self.core_coder_delay = reader.read(14) core_coder_delay = reader.read(14)
extension_flag = reader.read(1) extension_flag = reader.read(1)
if not channel_configuration: if not channel_configuration:
AacAudioRtpPacket.program_config_element(reader) raise core.InvalidPacketError('program_config_element not supported')
if audio_object_type in (6, 20): if audio_object_type in (6, 20):
self.layer_nr = reader.read(3) layer_nr = reader.read(3)
if extension_flag: if extension_flag:
if audio_object_type == 22: if audio_object_type == 22:
num_of_sub_frame = reader.read(5) num_of_sub_frame = reader.read(5)
@@ -144,14 +182,13 @@ class AacAudioRtpPacket:
if extension_flag_3 == 1: if extension_flag_3 == 1:
raise core.InvalidPacketError('extensionFlag3 == 1 not supported') raise core.InvalidPacketError('extensionFlag3 == 1 not supported')
@staticmethod return cls(audio_object_type)
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 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 @dataclass
class AudioSpecificConfig: class AudioSpecificConfig:
@@ -159,6 +196,7 @@ class AacAudioRtpPacket:
sampling_frequency_index: int sampling_frequency_index: int
sampling_frequency: int sampling_frequency: int
channel_configuration: int channel_configuration: int
ga_specific_config: AacAudioRtpPacket.GASpecificConfig
sbr_present_flag: int sbr_present_flag: int
ps_present_flag: int ps_present_flag: int
extension_audio_object_type: int extension_audio_object_type: int
@@ -182,44 +220,73 @@ class AacAudioRtpPacket:
7350, 7350,
] ]
def __init__(self, reader: BitReader) -> None: @classmethod
# AudioSpecificConfig - ISO/EIC 14496-3 Table 1.15 def for_simple_aac(
self.audio_object_type = AacAudioRtpPacket.audio_object_type(reader) cls,
self.sampling_frequency_index = reader.read(4) audio_object_type: int,
if self.sampling_frequency_index == 0xF: sampling_frequency: int,
self.sampling_frequency = reader.read(24) channel_configuration: int,
else: ) -> Self:
self.sampling_frequency = self.SAMPLING_FREQUENCIES[ if sampling_frequency not in cls.SAMPLING_FREQUENCIES:
self.sampling_frequency_index raise ValueError(f'invalid sampling frequency {sampling_frequency}')
]
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)
else:
self.extension_sampling_frequency = self.SAMPLING_FREQUENCIES[
self.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
if self.audio_object_type in (1, 2, 3, 4, 6, 7, 17, 19, 20, 21, 22, 23): ga_specific_config = AacAudioRtpPacket.GASpecificConfig(audio_object_type)
ga_specific_config = AacAudioRtpPacket.GASpecificConfig(
reader, self.channel_configuration, self.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
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:
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:
extension_sampling_frequency = cls.SAMPLING_FREQUENCIES[
extension_sampling_frequency_index
]
audio_object_type = AacAudioRtpPacket.read_audio_object_type(reader)
if audio_object_type == 22:
extension_channel_configuration = reader.read(4)
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: else:
raise core.InvalidPacketError( 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: # if self.extension_audio_object_type != 5 and bits_to_decode >= 16:
@@ -248,13 +315,44 @@ class AacAudioRtpPacket:
# self.extension_sampling_frequency = self.SAMPLING_FREQUENCIES[self.extension_sampling_frequency_index] # self.extension_sampling_frequency = self.SAMPLING_FREQUENCIES[self.extension_sampling_frequency_index]
# self.extension_channel_configuration = reader.read(4) # 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 @dataclass
class StreamMuxConfig: class StreamMuxConfig:
other_data_present: int other_data_present: int
other_data_len_bits: int other_data_len_bits: int
audio_specific_config: AacAudioRtpPacket.AudioSpecificConfig 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 # StreamMuxConfig - ISO/EIC 14496-3 Table 1.42
audio_mux_version = reader.read(1) audio_mux_version = reader.read(1)
if audio_mux_version == 1: if audio_mux_version == 1:
@@ -264,7 +362,7 @@ class AacAudioRtpPacket:
if audio_mux_version_a != 0: if audio_mux_version_a != 0:
raise core.InvalidPacketError('audioMuxVersionA != 0 not supported') raise core.InvalidPacketError('audioMuxVersionA != 0 not supported')
if audio_mux_version == 1: if audio_mux_version == 1:
tara_buffer_fullness = AacAudioRtpPacket.latm_value(reader) tara_buffer_fullness = AacAudioRtpPacket.read_latm_value(reader)
stream_cnt = 0 stream_cnt = 0
all_streams_same_time_framing = reader.read(1) all_streams_same_time_framing = reader.read(1)
num_sub_frames = reader.read(6) num_sub_frames = reader.read(6)
@@ -275,13 +373,13 @@ class AacAudioRtpPacket:
if num_layer != 0: if num_layer != 0:
raise core.InvalidPacketError('num_layer != 0 not supported') raise core.InvalidPacketError('num_layer != 0 not supported')
if audio_mux_version == 0: if audio_mux_version == 0:
self.audio_specific_config = AacAudioRtpPacket.AudioSpecificConfig( audio_specific_config = AacAudioRtpPacket.AudioSpecificConfig.from_bits(
reader reader
) )
else: else:
asc_len = AacAudioRtpPacket.latm_value(reader) asc_len = AacAudioRtpPacket.read_latm_value(reader)
marker = reader.bit_position marker = reader.bit_position
self.audio_specific_config = AacAudioRtpPacket.AudioSpecificConfig( audio_specific_config = AacAudioRtpPacket.AudioSpecificConfig.from_bits(
reader reader
) )
audio_specific_config_len = reader.bit_position - marker audio_specific_config_len = reader.bit_position - marker
@@ -299,36 +397,49 @@ class AacAudioRtpPacket:
f'frame_length_type {frame_length_type} not supported' f'frame_length_type {frame_length_type} not supported'
) )
self.other_data_present = reader.read(1) other_data_present = reader.read(1)
if self.other_data_present: other_data_len_bits = 0
if other_data_present:
if audio_mux_version == 1: if audio_mux_version == 1:
self.other_data_len_bits = AacAudioRtpPacket.latm_value(reader) other_data_len_bits = AacAudioRtpPacket.read_latm_value(reader)
else: else:
self.other_data_len_bits = 0
while True: while True:
self.other_data_len_bits *= 256 other_data_len_bits *= 256
other_data_len_esc = reader.read(1) 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: if other_data_len_esc == 0:
break break
crc_check_present = reader.read(1) crc_check_present = reader.read(1)
if crc_check_present: if crc_check_present:
crc_checksum = reader.read(8) 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 @dataclass
class AudioMuxElement: class AudioMuxElement:
payload: bytes
stream_mux_config: AacAudioRtpPacket.StreamMuxConfig stream_mux_config: AacAudioRtpPacket.StreamMuxConfig
payload: bytes
def __init__(self, reader: BitReader, mux_config_present: int): @classmethod
if mux_config_present == 0: def from_bits(cls, reader: BitReader) -> Self:
raise core.InvalidPacketError('muxConfigPresent == 0 not supported')
# AudioMuxElement - ISO/EIC 14496-3 Table 1.41 # AudioMuxElement - ISO/EIC 14496-3 Table 1.41
# (only supports mux_config_present=1)
use_same_stream_mux = reader.read(1) use_same_stream_mux = reader.read(1)
if use_same_stream_mux: if use_same_stream_mux:
raise core.InvalidPacketError('useSameStreamMux == 1 not supported') 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: # We only support:
# allStreamsSameTimeFraming == 1 # allStreamsSameTimeFraming == 1
@@ -344,19 +455,46 @@ class AacAudioRtpPacket:
if tmp != 255: if tmp != 255:
break 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: if stream_mux_config.other_data_present:
reader.skip(self.stream_mux_config.other_data_len_bits) reader.skip(stream_mux_config.other_data_len_bits)
# ByteAlign # ByteAlign
while reader.bit_position % 8: while reader.bit_position % 8:
reader.read(1) 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 # Parse the bit stream
reader = BitReader(data) 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): def to_adts(self):
# pylint: disable=line-too-long # pylint: disable=line-too-long
@@ -383,3 +521,11 @@ class AacAudioRtpPacket:
) )
+ self.audio_mux_element.payload + 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)

View File

@@ -1571,14 +1571,22 @@ class Connection(CompositeEventEmitter):
raise raise
def __str__(self): def __str__(self):
return ( if self.transport == BT_LE_TRANSPORT:
f'Connection(handle=0x{self.handle:04X}, ' return (
f'role={self.role_name}, ' f'Connection(transport=LE, handle=0x{self.handle:04X}, '
f'self_address={self.self_address}, ' f'role={self.role_name}, '
f'self_resolvable_address={self.self_resolvable_address}, ' f'self_address={self.self_address}, '
f'peer_address={self.peer_address}, ' f'self_resolvable_address={self.self_resolvable_address}, '
f'peer_resolvable_address={self.peer_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})'
)
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------

View File

@@ -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( @HCI_Event.event(
[ [

View File

@@ -1106,6 +1106,18 @@ class Host(AbortableEventEmitter):
event.status, 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): def on_hci_link_supervision_timeout_changed_event(self, event):
pass pass

110
bumble/rtp.py Normal file
View File

@@ -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)})'
)

View File

@@ -61,20 +61,23 @@ def codec_capabilities():
return MediaCodecCapabilities( return MediaCodecCapabilities(
media_type=AVDTP_AUDIO_MEDIA_TYPE, media_type=AVDTP_AUDIO_MEDIA_TYPE,
media_codec_type=A2DP_SBC_CODEC_TYPE, media_codec_type=A2DP_SBC_CODEC_TYPE,
media_codec_information=SbcMediaCodecInformation.from_lists( media_codec_information=SbcMediaCodecInformation(
sampling_frequencies=[48000, 44100, 32000, 16000], sampling_frequency=SbcMediaCodecInformation.SamplingFrequency.SF_48000
channel_modes=[ | SbcMediaCodecInformation.SamplingFrequency.SF_44100
SBC_MONO_CHANNEL_MODE, | SbcMediaCodecInformation.SamplingFrequency.SF_32000
SBC_DUAL_CHANNEL_MODE, | SbcMediaCodecInformation.SamplingFrequency.SF_16000,
SBC_STEREO_CHANNEL_MODE, channel_mode=SbcMediaCodecInformation.ChannelMode.MONO
SBC_JOINT_STEREO_CHANNEL_MODE, | SbcMediaCodecInformation.ChannelMode.DUAL_CHANNEL
], | SbcMediaCodecInformation.ChannelMode.STEREO
block_lengths=[4, 8, 12, 16], | SbcMediaCodecInformation.ChannelMode.JOINT_STEREO,
subbands=[4, 8], block_length=SbcMediaCodecInformation.BlockLength.BL_4
allocation_methods=[ | SbcMediaCodecInformation.BlockLength.BL_8
SBC_LOUDNESS_ALLOCATION_METHOD, | SbcMediaCodecInformation.BlockLength.BL_12
SBC_SNR_ALLOCATION_METHOD, | 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, minimum_bitpool_value=2,
maximum_bitpool_value=53, maximum_bitpool_value=53,
), ),

View File

@@ -33,8 +33,6 @@ from bumble.avdtp import (
Listener, Listener,
) )
from bumble.a2dp import ( from bumble.a2dp import (
SBC_JOINT_STEREO_CHANNEL_MODE,
SBC_LOUDNESS_ALLOCATION_METHOD,
make_audio_source_service_sdp_records, make_audio_source_service_sdp_records,
A2DP_SBC_CODEC_TYPE, A2DP_SBC_CODEC_TYPE,
SbcMediaCodecInformation, SbcMediaCodecInformation,
@@ -59,12 +57,12 @@ def codec_capabilities():
return MediaCodecCapabilities( return MediaCodecCapabilities(
media_type=AVDTP_AUDIO_MEDIA_TYPE, media_type=AVDTP_AUDIO_MEDIA_TYPE,
media_codec_type=A2DP_SBC_CODEC_TYPE, media_codec_type=A2DP_SBC_CODEC_TYPE,
media_codec_information=SbcMediaCodecInformation.from_discrete_values( media_codec_information=SbcMediaCodecInformation(
sampling_frequency=44100, sampling_frequency=SbcMediaCodecInformation.SamplingFrequency.SF_44100,
channel_mode=SBC_JOINT_STEREO_CHANNEL_MODE, channel_mode=SbcMediaCodecInformation.ChannelMode.JOINT_STEREO,
block_length=16, block_length=SbcMediaCodecInformation.BlockLength.BL_16,
subbands=8, subbands=SbcMediaCodecInformation.Subbands.S_8,
allocation_method=SBC_LOUDNESS_ALLOCATION_METHOD, allocation_method=SbcMediaCodecInformation.AllocationMethod.LOUDNESS,
minimum_bitpool_value=2, minimum_bitpool_value=2,
maximum_bitpool_value=53, maximum_bitpool_value=53,
), ),
@@ -73,11 +71,9 @@ def codec_capabilities():
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
def on_avdtp_connection(read_function, protocol): def on_avdtp_connection(read_function, protocol):
packet_source = SbcPacketSource( packet_source = SbcPacketSource(read_function, protocol.l2cap_channel.peer_mtu)
read_function, protocol.l2cap_channel.peer_mtu, codec_capabilities()
)
packet_pump = MediaPacketPump(packet_source.packets) 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}') print(f'### Selected sink: {sink.seid}')
# Stream the packets # Stream the packets
packet_source = SbcPacketSource( packet_source = SbcPacketSource(read_function, protocol.l2cap_channel.peer_mtu)
read_function, protocol.l2cap_channel.peer_mtu, codec_capabilities()
)
packet_pump = MediaPacketPump(packet_source.packets) 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) stream = await protocol.create_stream(source, sink)
await stream.start() await stream.start()
await asyncio.sleep(5) await asyncio.sleep(5)

View File

@@ -60,20 +60,23 @@ def codec_capabilities():
return avdtp.MediaCodecCapabilities( return avdtp.MediaCodecCapabilities(
media_type=avdtp.AVDTP_AUDIO_MEDIA_TYPE, media_type=avdtp.AVDTP_AUDIO_MEDIA_TYPE,
media_codec_type=a2dp.A2DP_SBC_CODEC_TYPE, media_codec_type=a2dp.A2DP_SBC_CODEC_TYPE,
media_codec_information=a2dp.SbcMediaCodecInformation.from_lists( media_codec_information=a2dp.SbcMediaCodecInformation(
sampling_frequencies=[48000, 44100, 32000, 16000], sampling_frequency=a2dp.SbcMediaCodecInformation.SamplingFrequency.SF_48000
channel_modes=[ | a2dp.SbcMediaCodecInformation.SamplingFrequency.SF_44100
a2dp.SBC_MONO_CHANNEL_MODE, | a2dp.SbcMediaCodecInformation.SamplingFrequency.SF_32000
a2dp.SBC_DUAL_CHANNEL_MODE, | a2dp.SbcMediaCodecInformation.SamplingFrequency.SF_16000,
a2dp.SBC_STEREO_CHANNEL_MODE, channel_mode=a2dp.SbcMediaCodecInformation.ChannelMode.MONO
a2dp.SBC_JOINT_STEREO_CHANNEL_MODE, | a2dp.SbcMediaCodecInformation.ChannelMode.DUAL_CHANNEL
], | a2dp.SbcMediaCodecInformation.ChannelMode.STEREO
block_lengths=[4, 8, 12, 16], | a2dp.SbcMediaCodecInformation.ChannelMode.JOINT_STEREO,
subbands=[4, 8], block_length=a2dp.SbcMediaCodecInformation.BlockLength.BL_4
allocation_methods=[ | a2dp.SbcMediaCodecInformation.BlockLength.BL_8
a2dp.SBC_LOUDNESS_ALLOCATION_METHOD, | a2dp.SbcMediaCodecInformation.BlockLength.BL_12
a2dp.SBC_SNR_ALLOCATION_METHOD, | 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, minimum_bitpool_value=2,
maximum_bitpool_value=53, maximum_bitpool_value=53,
), ),

View File

@@ -70,6 +70,7 @@ console_scripts =
bumble-usb-probe = bumble.apps.usb_probe:main bumble-usb-probe = bumble.apps.usb_probe:main
bumble-link-relay = bumble.apps.link_relay.link_relay:main bumble-link-relay = bumble.apps.link_relay.link_relay:main
bumble-bench = bumble.apps.bench:main bumble-bench = bumble.apps.bench:main
bumble-player = bumble.apps.player.player:main
bumble-speaker = bumble.apps.speaker.speaker:main bumble-speaker = bumble.apps.speaker.speaker:main
bumble-pandora-server = bumble.apps.pandora_server:main bumble-pandora-server = bumble.apps.pandora_server:main
bumble-rtk-util = bumble.tools.rtk_util:main bumble-rtk-util = bumble.tools.rtk_util:main

View File

@@ -33,20 +33,16 @@ from bumble.avdtp import (
Protocol, Protocol,
Listener, Listener,
MediaCodecCapabilities, MediaCodecCapabilities,
MediaPacket,
AVDTP_AUDIO_MEDIA_TYPE, AVDTP_AUDIO_MEDIA_TYPE,
AVDTP_TSEP_SNK, AVDTP_TSEP_SNK,
A2DP_SBC_CODEC_TYPE, A2DP_SBC_CODEC_TYPE,
) )
from bumble.a2dp import ( from bumble.a2dp import (
AacMediaCodecInformation,
OpusMediaCodecInformation,
SbcMediaCodecInformation, 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 # Logging
@@ -125,12 +121,12 @@ def source_codec_capabilities():
return MediaCodecCapabilities( return MediaCodecCapabilities(
media_type=AVDTP_AUDIO_MEDIA_TYPE, media_type=AVDTP_AUDIO_MEDIA_TYPE,
media_codec_type=A2DP_SBC_CODEC_TYPE, media_codec_type=A2DP_SBC_CODEC_TYPE,
media_codec_information=SbcMediaCodecInformation.from_discrete_values( media_codec_information=SbcMediaCodecInformation(
sampling_frequency=44100, sampling_frequency=SbcMediaCodecInformation.SamplingFrequency.SF_44100,
channel_mode=SBC_JOINT_STEREO_CHANNEL_MODE, channel_mode=SbcMediaCodecInformation.ChannelMode.JOINT_STEREO,
block_length=16, block_length=SbcMediaCodecInformation.BlockLength.BL_16,
subbands=8, subbands=SbcMediaCodecInformation.Subbands.S_8,
allocation_method=SBC_LOUDNESS_ALLOCATION_METHOD, allocation_method=SbcMediaCodecInformation.AllocationMethod.LOUDNESS,
minimum_bitpool_value=2, minimum_bitpool_value=2,
maximum_bitpool_value=53, maximum_bitpool_value=53,
), ),
@@ -142,20 +138,23 @@ def sink_codec_capabilities():
return MediaCodecCapabilities( return MediaCodecCapabilities(
media_type=AVDTP_AUDIO_MEDIA_TYPE, media_type=AVDTP_AUDIO_MEDIA_TYPE,
media_codec_type=A2DP_SBC_CODEC_TYPE, media_codec_type=A2DP_SBC_CODEC_TYPE,
media_codec_information=SbcMediaCodecInformation.from_lists( media_codec_information=SbcMediaCodecInformation(
sampling_frequencies=[48000, 44100, 32000, 16000], sampling_frequency=SbcMediaCodecInformation.SamplingFrequency.SF_48000
channel_modes=[ | SbcMediaCodecInformation.SamplingFrequency.SF_44100
SBC_MONO_CHANNEL_MODE, | SbcMediaCodecInformation.SamplingFrequency.SF_32000
SBC_DUAL_CHANNEL_MODE, | SbcMediaCodecInformation.SamplingFrequency.SF_16000,
SBC_STEREO_CHANNEL_MODE, channel_mode=SbcMediaCodecInformation.ChannelMode.MONO
SBC_JOINT_STEREO_CHANNEL_MODE, | SbcMediaCodecInformation.ChannelMode.DUAL_CHANNEL
], | SbcMediaCodecInformation.ChannelMode.STEREO
block_lengths=[4, 8, 12, 16], | SbcMediaCodecInformation.ChannelMode.JOINT_STEREO,
subbands=[4, 8], block_length=SbcMediaCodecInformation.BlockLength.BL_4
allocation_methods=[ | SbcMediaCodecInformation.BlockLength.BL_8
SBC_LOUDNESS_ALLOCATION_METHOD, | SbcMediaCodecInformation.BlockLength.BL_12
SBC_SNR_ALLOCATION_METHOD, | 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, minimum_bitpool_value=2,
maximum_bitpool_value=53, maximum_bitpool_value=53,
), ),
@@ -273,7 +272,125 @@ async def test_source_sink_1():
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
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_self_connection()
await test_source_sink_1() await test_source_sink_1()
@@ -281,4 +398,4 @@ async def run_test_self():
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
if __name__ == '__main__': if __name__ == '__main__':
logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'INFO').upper()) logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'INFO').upper())
asyncio.run(run_test_self()) asyncio.run(async_main())

View File

@@ -23,13 +23,12 @@ from bumble.avdtp import (
AVDTP_MEDIA_TRANSPORT_SERVICE_CATEGORY, AVDTP_MEDIA_TRANSPORT_SERVICE_CATEGORY,
AVDTP_SET_CONFIGURATION, AVDTP_SET_CONFIGURATION,
Message, Message,
MediaPacket,
Get_Capabilities_Response, Get_Capabilities_Response,
Set_Configuration_Command, Set_Configuration_Command,
Set_Configuration_Response,
ServiceCapabilities, ServiceCapabilities,
MediaCodecCapabilities, MediaCodecCapabilities,
) )
from bumble.rtp import MediaPacket
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------

View File

@@ -15,8 +15,9 @@
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Imports # Imports
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
import random
import pytest 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') 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(): def test_aac_rtp():
# pylint: disable=line-too-long # pylint: disable=line-too-long
packet_data = bytes.fromhex( packet_data = bytes.fromhex(
'47fc0000b090800300202066000198000de120000000000000000000000000000000000000000000001c' '47fc0000b090800300202066000198000de120000000000000000000000000000000000000000000001c'
) )
packet = AacAudioRtpPacket(packet_data) packet = AacAudioRtpPacket.from_bytes(packet_data)
adts = packet.to_adts() adts = packet.to_adts()
assert adts == bytes.fromhex( assert adts == bytes.fromhex(
'fff1508004fffc2066000198000de120000000000000000000000000000000000000000000001c' '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__': if __name__ == '__main__':
test_reader() test_reader()
test_writer()
test_aac_rtp() test_aac_rtp()

View File

@@ -28,26 +28,18 @@ from bumble.avdtp import (
AVDTP_AUDIO_MEDIA_TYPE, AVDTP_AUDIO_MEDIA_TYPE,
Listener, Listener,
MediaCodecCapabilities, MediaCodecCapabilities,
MediaPacket,
Protocol, Protocol,
) )
from bumble.a2dp import ( from bumble.a2dp import (
make_audio_sink_service_sdp_records, make_audio_sink_service_sdp_records,
MPEG_2_AAC_LC_OBJECT_TYPE,
A2DP_SBC_CODEC_TYPE, A2DP_SBC_CODEC_TYPE,
A2DP_MPEG_2_4_AAC_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, SbcMediaCodecInformation,
AacMediaCodecInformation, AacMediaCodecInformation,
) )
from bumble.utils import AsyncRunner
from bumble.codecs import AacAudioRtpPacket from bumble.codecs import AacAudioRtpPacket
from bumble.hci import HCI_Reset_Command from bumble.hci import HCI_Reset_Command
from bumble.rtp import MediaPacket
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@@ -72,7 +64,7 @@ class AudioExtractor:
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
class AacAudioExtractor: class AacAudioExtractor:
def extract_audio(self, packet: MediaPacket) -> bytes: 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 @@ class Speaker:
return MediaCodecCapabilities( return MediaCodecCapabilities(
media_type=AVDTP_AUDIO_MEDIA_TYPE, media_type=AVDTP_AUDIO_MEDIA_TYPE,
media_codec_type=A2DP_MPEG_2_4_AAC_CODEC_TYPE, media_codec_type=A2DP_MPEG_2_4_AAC_CODEC_TYPE,
media_codec_information=AacMediaCodecInformation.from_lists( media_codec_information=AacMediaCodecInformation(
object_types=[MPEG_2_AAC_LC_OBJECT_TYPE], object_type=AacMediaCodecInformation.ObjectType.MPEG_2_AAC_LC,
sampling_frequencies=[48000, 44100], sampling_frequency=AacMediaCodecInformation.SamplingFrequency.SF_48000
channels=[1, 2], | AacMediaCodecInformation.SamplingFrequency.SF_44100,
channels=AacMediaCodecInformation.Channels.MONO
| AacMediaCodecInformation.Channels.STEREO,
vbr=1, vbr=1,
bitrate=256000, bitrate=256000,
), ),
@@ -143,20 +137,23 @@ class Speaker:
return MediaCodecCapabilities( return MediaCodecCapabilities(
media_type=AVDTP_AUDIO_MEDIA_TYPE, media_type=AVDTP_AUDIO_MEDIA_TYPE,
media_codec_type=A2DP_SBC_CODEC_TYPE, media_codec_type=A2DP_SBC_CODEC_TYPE,
media_codec_information=SbcMediaCodecInformation.from_lists( media_codec_information=SbcMediaCodecInformation(
sampling_frequencies=[48000, 44100, 32000, 16000], sampling_frequency=SbcMediaCodecInformation.SamplingFrequency.SF_48000
channel_modes=[ | SbcMediaCodecInformation.SamplingFrequency.SF_44100
SBC_MONO_CHANNEL_MODE, | SbcMediaCodecInformation.SamplingFrequency.SF_32000
SBC_DUAL_CHANNEL_MODE, | SbcMediaCodecInformation.SamplingFrequency.SF_16000,
SBC_STEREO_CHANNEL_MODE, channel_mode=SbcMediaCodecInformation.ChannelMode.MONO
SBC_JOINT_STEREO_CHANNEL_MODE, | SbcMediaCodecInformation.ChannelMode.DUAL_CHANNEL
], | SbcMediaCodecInformation.ChannelMode.STEREO
block_lengths=[4, 8, 12, 16], | SbcMediaCodecInformation.ChannelMode.JOINT_STEREO,
subbands=[4, 8], block_length=SbcMediaCodecInformation.BlockLength.BL_4
allocation_methods=[ | SbcMediaCodecInformation.BlockLength.BL_8
SBC_LOUDNESS_ALLOCATION_METHOD, | SbcMediaCodecInformation.BlockLength.BL_12
SBC_SNR_ALLOCATION_METHOD, | 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, minimum_bitpool_value=2,
maximum_bitpool_value=53, maximum_bitpool_value=53,
), ),
@@ -282,9 +279,6 @@ class Speaker:
mitm=False mitm=False
) )
# Start the controller
await self.device.power_on()
# Listen for Bluetooth connections # Listen for Bluetooth connections
self.device.on('connection', self.on_bluetooth_connection) self.device.on('connection', self.on_bluetooth_connection)
@@ -295,6 +289,9 @@ class Speaker:
self.avdtp_listener = Listener.for_device(self.device) self.avdtp_listener = Listener.for_device(self.device)
self.avdtp_listener.on('connection', self.on_avdtp_connection) 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}') print(f'Speaker ready to play, codec={self.codec}')
if connect_address: if connect_address: