A2DP example: Codec selection based on file type

Currently support SBC and AAC
This commit is contained in:
Charlie Boutier
2025-02-26 15:46:34 -08:00
parent a88a034ce2
commit 7237619d3b
3 changed files with 159 additions and 52 deletions

View File

@@ -33,7 +33,7 @@ jobs:
- name: Install dependencies - name: Install dependencies
run: | run: |
python -m pip install --upgrade pip python -m pip install --upgrade pip
python -m pip install ".[build,test,development]" python -m pip install ".[build,examples,test,development]"
- name: Check - name: Check
run: | run: |
invoke project.pre-commit invoke project.pre-commit

View File

@@ -16,28 +16,43 @@
# Imports # Imports
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
import asyncio import asyncio
import sys
import os
import logging import logging
import os
import sys
from dataclasses import dataclass
from bumble.colors import color import ffmpeg
from bumble.device import Device
from bumble.transport import open_transport_or_link from bumble.a2dp import (
from bumble.core import PhysicalTransport A2DP_MPEG_2_4_AAC_CODEC_TYPE,
A2DP_SBC_CODEC_TYPE,
AacMediaCodecInformation,
AacPacketSource,
SbcMediaCodecInformation,
SbcPacketSource,
make_audio_source_service_sdp_records,
)
from bumble.avdtp import ( from bumble.avdtp import (
find_avdtp_service_with_connection,
AVDTP_AUDIO_MEDIA_TYPE, AVDTP_AUDIO_MEDIA_TYPE,
Listener,
MediaCodecCapabilities, MediaCodecCapabilities,
MediaPacketPump, MediaPacketPump,
Protocol, Protocol,
Listener, find_avdtp_service_with_connection,
)
from bumble.a2dp import (
make_audio_source_service_sdp_records,
A2DP_SBC_CODEC_TYPE,
SbcMediaCodecInformation,
SbcPacketSource,
) )
from bumble.colors import color
from bumble.core import PhysicalTransport
from bumble.device import Device
from bumble.transport import open_transport_or_link
from typing import Dict, Union
@dataclass
class CodecCapabilities:
name: str
sample_rate: str
number_of_channels: str
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@@ -51,67 +66,147 @@ def sdp_records():
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
def codec_capabilities(): def on_avdtp_connection(
# NOTE: this shouldn't be hardcoded, but should be inferred from the input file read_function, protocol, codec_capabilities: MediaCodecCapabilities
# instead ):
return MediaCodecCapabilities(
media_type=AVDTP_AUDIO_MEDIA_TYPE,
media_codec_type=A2DP_SBC_CODEC_TYPE,
media_codec_information=SbcMediaCodecInformation(
sampling_frequency=SbcMediaCodecInformation.SamplingFrequency.SF_44100,
channel_mode=SbcMediaCodecInformation.ChannelMode.JOINT_STEREO,
block_length=SbcMediaCodecInformation.BlockLength.BL_16,
subbands=SbcMediaCodecInformation.Subbands.S_8,
allocation_method=SbcMediaCodecInformation.AllocationMethod.LOUDNESS,
minimum_bitpool_value=2,
maximum_bitpool_value=53,
),
)
# -----------------------------------------------------------------------------
def on_avdtp_connection(read_function, protocol):
packet_source = SbcPacketSource(read_function, protocol.l2cap_channel.peer_mtu) packet_source = SbcPacketSource(read_function, protocol.l2cap_channel.peer_mtu)
packet_pump = MediaPacketPump(packet_source.packets) packet_pump = MediaPacketPump(packet_source.packets)
protocol.add_source(codec_capabilities(), packet_pump) protocol.add_source(codec_capabilities, packet_pump)
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
async def stream_packets(read_function, protocol): async def stream_packets(
read_function, protocol, codec_capabilities: MediaCodecCapabilities
):
# Discover all endpoints on the remote device # Discover all endpoints on the remote device
endpoints = await protocol.discover_remote_endpoints() endpoints = await protocol.discover_remote_endpoints()
for endpoint in endpoints: for endpoint in endpoints:
print('@@@', endpoint) print('@@@', endpoint)
# Select a sink # Select a sink
assert codec_capabilities.media_codec_type in [
A2DP_SBC_CODEC_TYPE,
A2DP_MPEG_2_4_AAC_CODEC_TYPE,
]
sink = protocol.find_remote_sink_by_codec( sink = protocol.find_remote_sink_by_codec(
AVDTP_AUDIO_MEDIA_TYPE, A2DP_SBC_CODEC_TYPE AVDTP_AUDIO_MEDIA_TYPE, codec_capabilities.media_codec_type
) )
if sink is None: if sink is None:
print(color('!!! no SBC sink found', 'red')) print(color('!!! no Sink found', 'red'))
return return
print(f'### Selected sink: {sink.seid}') print(f'### Selected sink: {sink.seid}')
# Stream the packets # Stream the packets
packet_source = SbcPacketSource(read_function, protocol.l2cap_channel.peer_mtu) packet_sources = {
packet_pump = MediaPacketPump(packet_source.packets) A2DP_SBC_CODEC_TYPE: SbcPacketSource(
source = protocol.add_source(codec_capabilities(), packet_pump) read_function, protocol.l2cap_channel.peer_mtu
),
A2DP_MPEG_2_4_AAC_CODEC_TYPE: AacPacketSource(
read_function, protocol.l2cap_channel.peer_mtu
),
}
packet_source = packet_sources[codec_capabilities.media_codec_type]
packet_pump = MediaPacketPump(packet_source.packets) # type: ignore
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(60)
await stream.stop()
await asyncio.sleep(5)
await stream.start()
await asyncio.sleep(5)
await stream.stop() await stream.stop()
await stream.close() await stream.close()
# -----------------------------------------------------------------------------
def fetch_codec_informations(filepath) -> MediaCodecCapabilities:
probe = ffmpeg.probe(filepath)
assert 'streams' in probe
streams = probe['streams']
if not streams or len(streams) > 1:
print(streams)
print(color('!!! file not supported', 'red'))
exit()
audio_stream = streams[0]
media_codec_type = None
media_codec_information: Union[
SbcMediaCodecInformation, AacMediaCodecInformation, None
] = None
assert 'codec_name' in audio_stream
codec_name: str = audio_stream['codec_name']
if codec_name == "sbc":
media_codec_type = A2DP_SBC_CODEC_TYPE
sbc_sampling_frequency: Dict[
str, SbcMediaCodecInformation.SamplingFrequency
] = {
'16000': SbcMediaCodecInformation.SamplingFrequency.SF_16000,
'32000': SbcMediaCodecInformation.SamplingFrequency.SF_32000,
'44100': SbcMediaCodecInformation.SamplingFrequency.SF_44100,
'48000': SbcMediaCodecInformation.SamplingFrequency.SF_48000,
}
sbc_channel_mode: Dict[int, SbcMediaCodecInformation.ChannelMode] = {
1: SbcMediaCodecInformation.ChannelMode.MONO,
2: SbcMediaCodecInformation.ChannelMode.JOINT_STEREO,
}
assert 'sample_rate' in audio_stream
assert 'channels' in audio_stream
media_codec_information = SbcMediaCodecInformation(
sampling_frequency=sbc_sampling_frequency[audio_stream['sample_rate']],
channel_mode=sbc_channel_mode[audio_stream['channels']],
block_length=SbcMediaCodecInformation.BlockLength.BL_16,
subbands=SbcMediaCodecInformation.Subbands.S_8,
allocation_method=SbcMediaCodecInformation.AllocationMethod.LOUDNESS,
minimum_bitpool_value=2,
maximum_bitpool_value=53,
)
elif codec_name == "aac":
media_codec_type = A2DP_MPEG_2_4_AAC_CODEC_TYPE
object_type: Dict[str, AacMediaCodecInformation.ObjectType] = {
'LC': AacMediaCodecInformation.ObjectType.MPEG_2_AAC_LC,
'LTP': AacMediaCodecInformation.ObjectType.MPEG_4_AAC_LTP,
'SSR': AacMediaCodecInformation.ObjectType.MPEG_4_AAC_SCALABLE,
}
aac_sampling_frequency: Dict[
str, AacMediaCodecInformation.SamplingFrequency
] = {
'44100': AacMediaCodecInformation.SamplingFrequency.SF_44100,
'48000': AacMediaCodecInformation.SamplingFrequency.SF_48000,
}
aac_channel_mode: Dict[int, AacMediaCodecInformation.Channels] = {
1: AacMediaCodecInformation.Channels.MONO,
2: AacMediaCodecInformation.Channels.STEREO,
}
assert 'profile' in audio_stream
assert 'sample_rate' in audio_stream
assert 'channels' in audio_stream
media_codec_information = AacMediaCodecInformation(
object_type=object_type[audio_stream['profile']],
sampling_frequency=aac_sampling_frequency[audio_stream['sample_rate']],
channels=aac_channel_mode[audio_stream['channels']],
vbr=1,
bitrate=128000,
)
else:
print(color('!!! codec not supported, only aac & sbc are supported', 'red'))
exit()
assert media_codec_type is not None
assert media_codec_information is not None
return MediaCodecCapabilities(
media_type=AVDTP_AUDIO_MEDIA_TYPE,
media_codec_type=media_codec_type,
media_codec_information=media_codec_information,
)
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
async def main() -> None: async def main() -> None:
if len(sys.argv) < 4: if len(sys.argv) < 4:
print( print(
'Usage: run_a2dp_source.py <device-config> <transport-spec> <sbc-file> ' 'Usage: run_a2dp_source.py <device-config> <transport-spec> <audio-file> '
'[<bluetooth-address>]' '[<bluetooth-address>]'
) )
print( print(
@@ -135,11 +230,13 @@ async def main() -> None:
# Start # Start
await device.power_on() await device.power_on()
with open(sys.argv[3], 'rb') as sbc_file: with open(sys.argv[3], 'rb') as audio_file:
# NOTE: this should be using asyncio file reading, but blocking reads are # NOTE: this should be using asyncio file reading, but blocking reads are
# good enough for testing # good enough for testing
async def read(byte_count): async def read(byte_count):
return sbc_file.read(byte_count) return audio_file.read(byte_count)
codec_capabilities = fetch_codec_informations(sys.argv[3])
if len(sys.argv) > 4: if len(sys.argv) > 4:
# Connect to a peer # Connect to a peer
@@ -170,12 +267,15 @@ async def main() -> None:
protocol = await Protocol.connect(connection, avdtp_version) protocol = await Protocol.connect(connection, avdtp_version)
# Start streaming # Start streaming
await stream_packets(read, protocol) await stream_packets(read, protocol, codec_capabilities)
else: else:
# Create a listener to wait for AVDTP connections # Create a listener to wait for AVDTP connections
listener = Listener.for_device(device=device, version=(1, 2)) listener = Listener.for_device(device=device, version=(1, 2))
listener.on( listener.on(
'connection', lambda protocol: on_avdtp_connection(read, protocol) 'connection',
lambda protocol: on_avdtp_connection(
read, protocol, codec_capabilities
),
) )
# Become connectable and wait for a connection # Become connectable and wait for a connection

View File

@@ -55,6 +55,9 @@ development = [
"types-invoke >= 1.7.3", "types-invoke >= 1.7.3",
"types-protobuf >= 4.21.0", "types-protobuf >= 4.21.0",
] ]
examples = [
"ffmpeg-python == 0.2.0",
]
avatar = [ avatar = [
"pandora-avatar == 0.0.10", "pandora-avatar == 0.0.10",
"rootcanal == 1.11.1 ; python_version>='3.10'", "rootcanal == 1.11.1 ; python_version>='3.10'",
@@ -184,6 +187,10 @@ ignore_missing_imports = true
module = "construct.*" module = "construct.*"
ignore_missing_imports = true ignore_missing_imports = true
[[tool.mypy.overrides]]
module = "ffmpeg.*"
ignore_missing_imports = true
[[tool.mypy.overrides]] [[tool.mypy.overrides]]
module = "grpc.*" module = "grpc.*"
ignore_missing_imports = true ignore_missing_imports = true