From 7237619d3bab9aba4812cceb2c1a61f1c2b8a921 Mon Sep 17 00:00:00 2001 From: Charlie Boutier Date: Wed, 26 Feb 2025 15:46:34 -0800 Subject: [PATCH] A2DP example: Codec selection based on file type Currently support SBC and AAC --- .github/workflows/code-check.yml | 2 +- examples/run_a2dp_source.py | 202 +++++++++++++++++++++++-------- pyproject.toml | 7 ++ 3 files changed, 159 insertions(+), 52 deletions(-) diff --git a/.github/workflows/code-check.yml b/.github/workflows/code-check.yml index d7e3ea5..cdd9282 100644 --- a/.github/workflows/code-check.yml +++ b/.github/workflows/code-check.yml @@ -33,7 +33,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - python -m pip install ".[build,test,development]" + python -m pip install ".[build,examples,test,development]" - name: Check run: | invoke project.pre-commit diff --git a/examples/run_a2dp_source.py b/examples/run_a2dp_source.py index ba4e24e..5b79db8 100644 --- a/examples/run_a2dp_source.py +++ b/examples/run_a2dp_source.py @@ -16,28 +16,43 @@ # Imports # ----------------------------------------------------------------------------- import asyncio -import sys -import os import logging +import os +import sys +from dataclasses import dataclass -from bumble.colors import color -from bumble.device import Device -from bumble.transport import open_transport_or_link -from bumble.core import PhysicalTransport +import ffmpeg + +from bumble.a2dp import ( + 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 ( - find_avdtp_service_with_connection, AVDTP_AUDIO_MEDIA_TYPE, + Listener, MediaCodecCapabilities, MediaPacketPump, Protocol, - Listener, -) -from bumble.a2dp import ( - make_audio_source_service_sdp_records, - A2DP_SBC_CODEC_TYPE, - SbcMediaCodecInformation, - SbcPacketSource, + find_avdtp_service_with_connection, ) +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(): - # NOTE: this shouldn't be hardcoded, but should be inferred from the input file - # 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): +def on_avdtp_connection( + read_function, protocol, codec_capabilities: MediaCodecCapabilities +): packet_source = SbcPacketSource(read_function, protocol.l2cap_channel.peer_mtu) 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 endpoints = await protocol.discover_remote_endpoints() for endpoint in endpoints: print('@@@', endpoint) # 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( - AVDTP_AUDIO_MEDIA_TYPE, A2DP_SBC_CODEC_TYPE + AVDTP_AUDIO_MEDIA_TYPE, codec_capabilities.media_codec_type ) if sink is None: - print(color('!!! no SBC sink found', 'red')) + print(color('!!! no Sink found', 'red')) return print(f'### Selected sink: {sink.seid}') # Stream the packets - packet_source = SbcPacketSource(read_function, protocol.l2cap_channel.peer_mtu) - packet_pump = MediaPacketPump(packet_source.packets) - source = protocol.add_source(codec_capabilities(), packet_pump) + packet_sources = { + A2DP_SBC_CODEC_TYPE: SbcPacketSource( + 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) await stream.start() - await asyncio.sleep(5) - await stream.stop() - await asyncio.sleep(5) - await stream.start() - await asyncio.sleep(5) + await asyncio.sleep(60) await stream.stop() 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: if len(sys.argv) < 4: print( - 'Usage: run_a2dp_source.py ' + 'Usage: run_a2dp_source.py ' '[]' ) print( @@ -135,11 +230,13 @@ async def main() -> None: # Start 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 # good enough for testing 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: # Connect to a peer @@ -170,12 +267,15 @@ async def main() -> None: protocol = await Protocol.connect(connection, avdtp_version) # Start streaming - await stream_packets(read, protocol) + await stream_packets(read, protocol, codec_capabilities) else: # Create a listener to wait for AVDTP connections listener = Listener.for_device(device=device, version=(1, 2)) 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 diff --git a/pyproject.toml b/pyproject.toml index 9474cd1..479635f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -55,6 +55,9 @@ development = [ "types-invoke >= 1.7.3", "types-protobuf >= 4.21.0", ] +examples = [ + "ffmpeg-python == 0.2.0", +] avatar = [ "pandora-avatar == 0.0.10", "rootcanal == 1.11.1 ; python_version>='3.10'", @@ -184,6 +187,10 @@ ignore_missing_imports = true module = "construct.*" ignore_missing_imports = true +[[tool.mypy.overrides]] +module = "ffmpeg.*" +ignore_missing_imports = true + [[tool.mypy.overrides]] module = "grpc.*" ignore_missing_imports = true