diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e69de29 diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/device.json b/apps/device.json new file mode 100644 index 0000000..05a33f0 --- /dev/null +++ b/apps/device.json @@ -0,0 +1,4 @@ +{ + "name": "Unicast Client", + "address": "C0:98:E5:49:00:00" +} \ No newline at end of file diff --git a/apps/unicast_client.py b/apps/unicast_client.py new file mode 100644 index 0000000..498bf44 --- /dev/null +++ b/apps/unicast_client.py @@ -0,0 +1,477 @@ +from scipy import signal +import numpy as np +import argparse +from bumble.profiles.bap import ( + + AudioLocation, + + SamplingFrequency, + + FrameDuration, + + CodecSpecificConfiguration, + +) +from bumble.profiles.ascs import ( + + AudioStreamControlServiceProxy, + + ASE_Config_Codec, + + ASE_Config_QOS, + + ASE_Disable, + + ASE_Enable, + +) +from bumble.hci import CodecID, CodingFormat +from bumble.profiles.pacs import ( + + PacRecord, + + PublishedAudioCapabilitiesServiceProxy, + +) +from bumble.transport.common import StreamPacketSource +from bumble.profiles import pacs +from bumble.utils import AsyncRunner +from bumble.transport import open_transport_or_link +from bumble.device import Device, Peer, Advertisement, ConnectionParametersPreferences, Connection +from bumble.core import ProtocolError, AdvertisingData +from bumble.snoop import BtSnooper +import functools +from bumble.colors import color +from bumble.profiles.ascs import AudioStreamControlServiceProxy +from bumble.hci import HCI_IsoDataPacket, HCI_LE_1M_PHY, HCI_LE_2M_PHY +from bumble import device +from typing import Optional, List, cast +import scipy.io.wavfile as wav +import logging +import sys +sys.path.append('../utils') +from le_audio_encoder import LeAudioEncoder +import asyncio +import sys +import time +import os +from bumble import hci +import sys + + +app_specific_codec = CodecSpecificConfiguration( + sampling_frequency=SamplingFrequency.FREQ_24000, + frame_duration=FrameDuration.DURATION_10000_US, + audio_channel_allocation=AudioLocation.FRONT_RIGHT, + octets_per_codec_frame=60, + codec_frames_per_sdu=1, +) + + +TEST_SINE = 1 + + +complete_local_name = "BLE_COVER" +iso_packets = [] + +upsampled_left_channel = None + + +def read_wav_file(filename): + + rate, data = wav.read(filename) + + print("Bitdepth:", data.dtype.itemsize * 8) + + print("Sample rate:", rate) + + left_channel = data[:, 1] + + print("Audio data (left):", left_channel) + + print(len(left_channel)) + print(app_specific_codec.sampling_frequency.hz) + upsampled_data = signal.resample(left_channel, int( + app_specific_codec.sampling_frequency.hz / 41000 * left_channel.shape[0])) + + wav.write("upsampled_stereo_file.wav", app_specific_codec.sampling_frequency.hz, upsampled_data.astype(data.dtype)) + + return upsampled_data.astype(np.int16) + + +def generate_sine_wave_iso_frames(frequency, sampling_rate, duration): + num_samples = int(sampling_rate * duration) + + t = np.linspace(0, duration, num_samples, False) + + sine_wave = np.sin(2 * np.pi * frequency * t) + + # Scale the sine wave to the 16-bit range (-32768 to 32767) + + scaled_sine_wave = sine_wave * 8191.5 + + # Convert to 16-bit integer format + int16_sine_wave = scaled_sine_wave.astype(np.int16) + + iso_frame = bytearray() + + for num in int16_sine_wave: + + iso_frame.append(num & 0xFF) # Extract lower 8 bits + + iso_frame.append((num >> 8) & 0xFF) # Extract upper 8 bit + + return iso_frame + + +# ----------------------------------------------------------------------------- + +class Listener(Device.Listener): + + packet_sequence_number = 0 + + def __init__(self, device): + + self.device = device + + @AsyncRunner.run_in_task() + async def on_advertisement(self, advertisement): + + def parse_ltv_packet(data): + + packets = [] + + i = 0 + + while i < len(data)-1: + + length_byte = data[i] + + type_byte = data[i + 1] + + value_bytes = data[i+2:i+length_byte+1] + + packets.append((length_byte, type_byte, value_bytes)) + + i += length_byte+1 + + return packets + + ltv_packets = parse_ltv_packet(advertisement.data_bytes) + + for _, type, values in ltv_packets: + + if type is AdvertisingData.COMPLETE_LOCAL_NAME: + + to_compare = bytes(complete_local_name, "utf-8") + + if values == to_compare: + + print("found device " + complete_local_name) + + await self.device.stop_scanning() + + params = ConnectionParametersPreferences() + + params.connection_interval_min = (47) + + params.connection_interval_max = (47) + + prefs = {HCI_LE_1M_PHY: params, HCI_LE_2M_PHY: params} + + await self.device.connect(peer_address=advertisement.address, connection_parameters_preferences=prefs) + + @AsyncRunner.run_in_task() + async def on_connection(self, connection: Connection): + + notifications = {1: asyncio.Queue()} + + def on_notification(data: bytes, ase_id: int): + + notifications[ase_id].put_nowait(data) + + print(f'=== Connected to {connection}') + + peer = Peer(connection) + + # set PHY to 2M + await connection.set_phy(rx_phys=[HCI_LE_2M_PHY], tx_phys=[HCI_LE_2M_PHY]) + + # pair with the device + await connection.pair() + + # request mtu change + mtu = await peer.request_mtu(1691) + + # get remote features + remote_features = await self.device.get_remote_le_features(connection) + + print(f"peer supports the following features:{remote_features}") + + # discover services + pacs_client = await peer.discover_service_and_create_proxy(PublishedAudioCapabilitiesServiceProxy) + ascs_client = await peer.discover_service_and_create_proxy(AudioStreamControlServiceProxy) + + # read sink PACs + response = await pacs_client.sink_pac.read_value() + pac_record = PacRecord.from_bytes(response[1:]) + print(pac_record) + + # enable ASCS notifications + await ascs_client.ase_control_point.subscribe() + await ascs_client.sink_ase[0].subscribe( + functools.partial(on_notification, ase_id=1) + ) + + # read sink ASE state + sink_state = await ascs_client.sink_ase[0].read_value() + print(sink_state) + + print(app_specific_codec) + await ascs_client.ase_control_point.write_value( + ASE_Config_Codec( + ase_id=[1], + target_latency=[0x3], + target_phy=[2], + codec_id=[CodingFormat(CodecID.LC3)], + codec_specific_configuration=[app_specific_codec], + ) + + + ) + + # wait for notification + await notifications[1].get() + print("ASE: codec configured") + + # setup the CIG + cis_handles = await self.device.setup_cig( + cig_id=1, + cis_id=[1], + sdu_interval=(app_specific_codec.frame_duration.us, app_specific_codec.frame_duration.us), + framing=0, + max_sdu=(app_specific_codec.octets_per_codec_frame, 0), + retransmission_number=15, + max_transport_latency=(95, 95), + ) + + # configure ASE (config QOS) + await ascs_client.ase_control_point.write_value( + + ASE_Config_QOS( + ase_id=[1], + cig_id=[1], + cis_id=[1], + sdu_interval=[app_specific_codec.frame_duration.us], + framing=[0], + phy=[2], + max_sdu=[app_specific_codec.octets_per_codec_frame], + retransmission_number=[15], + max_transport_latency=[95], + presentation_delay=[40000], + ) + ) + + # wait for notifications + await notifications[1].get() + print("ASE: QOS configured") + + # configure ASE (Enable) + await ascs_client.ase_control_point.write_value( + ASE_Enable( + ase_id=[1], + metadata=[bytes([0x03, 0x02, 0x01, 0x00])], + ) + ) + + # wait for notifications + await notifications[1].get() + print('ASE: enabling') + + # create CIS + await self.device.create_cis( + [ + (cis_handles[0], connection.handle) + ] + ) + + print('ASE: cis established') + + await self.device.send_command( + hci.HCI_LE_Setup_ISO_Data_Path_Command( + connection_handle=cis_handles[0], + data_path_direction=hci.HCI_LE_Setup_ISO_Data_Path_Command.Direction.HOST_TO_CONTROLLER, + data_path_id=0x00, # Fixed HCI + codec_id=hci.CodingFormat(hci.CodecID.TRANSPARENT), + controller_delay=0, + codec_configuration=b'', + )) + + # wait for notifications + await notifications[1].get() + print('ASE: audio stream enabled') + + # prepere the ISO packets + self.packet_sequence_number = 0 + self.iso_packet = HCI_IsoDataPacket( + connection_handle=cis_handles[0], + data_total_length=app_specific_codec.octets_per_codec_frame + 4, + packet_sequence_number=self.packet_sequence_number, + pb_flag=0b10, + packet_status_flag=0, + iso_sdu_length=app_specific_codec.octets_per_codec_frame, + iso_sdu_fragment=bytes([0]*app_specific_codec.octets_per_codec_frame), + ) + + self.send_complete = False + def on_iso_pdu_sent(event): + if self.packet_sequence_number < len(iso_packets) - 1: + # send the next ISO packet + self.packet_sequence_number += 1 + self.iso_packet.packet_sequence_number = self.packet_sequence_number + self.iso_packet.iso_sdu_fragment = iso_packets[self.packet_sequence_number] + self.device.host.send_hci_packet(self.iso_packet) + else: + self.send_complete = True + + self.device.host.on('iso_packet_sent', on_iso_pdu_sent) + self.device.host.send_hci_packet(self.iso_packet) + + while True: + await asyncio.sleep(1) + if self.send_complete: + print("send complete. bye bye") + await self.device.power_off() + self.device.future.set_result(None) + break + +# ----------------------------------------------------------------------------- + + +async def main() -> None: + global complete_local_name + parser = argparse.ArgumentParser( + description="A simple example of argparse") + parser.add_argument("-c", "--config", type=str, + default="device.json", help="device config file") + parser.add_argument( + "-p", "--port", help="com port (e.g. serial:/dev/ttyUSB0)") + parser.add_argument("-s", "--sample_rate", type=int, + default=0, help="choose a sample rate") + parser.add_argument("-f", "--frame_duration", type=int, + default=0, help="choose a frame duration") + parser.add_argument("-w", "--wave", type=str, + help="choose a frame duration") + parser.add_argument("-t", "--target_name", type=str, + help="target complete local name of the peer") + parser.add_argument("--verbose", "-v", action="count", default=0) + args = parser.parse_args() + + + if args.verbose > 0: + logging.basicConfig(level=logging.DEBUG) + else: + + logging.basicConfig(level=logging.ERROR) + + print("sample rate", args.sample_rate) + if not args.sample_rate or args.sample_rate == 0: + app_specific_codec.sampling_frequency = SamplingFrequency.FREQ_16000 + app_specific_codec.frame_duration = FrameDuration.DURATION_10000_US + app_specific_codec.octets_per_codec_frame = 40 + elif args.sample_rate == 1: + app_specific_codec.sampling_frequency = SamplingFrequency.FREQ_24000 + app_specific_codec.frame_duration = FrameDuration.DURATION_10000_US + app_specific_codec.octets_per_codec_frame = 60 + elif args.sample_rate == 2: + app_specific_codec.sampling_frequency = SamplingFrequency.FREQ_48000 + app_specific_codec.frame_duration = FrameDuration.DURATION_10000_US + app_specific_codec.octets_per_codec_frame = 120 + else: + raise ValueError("unknown sample rate") + + print(f"sample rate: {app_specific_codec.sampling_frequency.hz} Hz") + print(f"frame duration: {app_specific_codec.frame_duration.us} us") + print(f"octets per codec frame: {app_specific_codec.octets_per_codec_frame} bytes") + + async with await open_transport_or_link(args.port) as hci_transport: + + # Create a device to manage the host, with a custom listener + device = Device.from_config_file_with_hci( + args.config, hci_transport.source, hci_transport.sink + ) + + # Connect to + if args.target_name: + complete_local_name = args.target_name + + device.listener = Listener(device) + device.cis_enabled = True + + f = open("log.btsnoop", "wb") + + Snooper = BtSnooper(f) + + device.host.snooper = Snooper + + if args.wave: + sound_file = args.wave + TEST_SINE = 0 + else: + TEST_SINE = 1 + + await device.power_on() + encoder = LeAudioEncoder() + encoder.setup_encoders( + app_specific_codec.sampling_frequency.hz, + app_specific_codec.frame_duration.us, + 1, + ) + + # prepare the samples + num_runs = 0 + # calculate the number of samples per frame duration. + sample_size = int(app_specific_codec.sampling_frequency.hz * app_specific_codec.frame_duration.us / 1000 / 1000) + if TEST_SINE == 0: + + if os.path.isfile(sound_file): + upsampled_left_channel = read_wav_file(sound_file) + else: + raise FileNotFoundError(f"The file {sound_file} does not exist.") + + num_runs = len(upsampled_left_channel) // sample_size + + else: + + num_runs = 2000 + + + print("sample size", sample_size) + for i in range(num_runs): + + if TEST_SINE == 0: + + pcm_data = upsampled_left_channel[i *sample_size:i*sample_size+sample_size] + + else: + + pcm_data = generate_sine_wave_iso_frames( + 1000, app_specific_codec.sampling_frequency.hz, app_specific_codec.frame_duration.us / 1000000) + + data = encoder.encode(app_specific_codec.octets_per_codec_frame, 1, 1, bytes(pcm_data)) + iso_packets.append(data) + + print("finished with encoding", len(iso_packets)) + + print(f'start scanning for {complete_local_name}...') + await device.start_scanning(scanning_phys=[HCI_LE_1M_PHY], legacy=False) + + device.future = asyncio.get_running_loop().create_future() + await device.future + print("done") + + +# ----------------------------------------------------------------------------- + + +asyncio.run(main()) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..e2975c9 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,25 @@ +[tool.poetry] +name = "leaudio" +version = "0.1.0" +description = "A LE Audio package" +authors = ["Your Name "] +license = "MIT" +readme = "README.md" +homepage = "https://example.com" +repository = "https://github.com/yourusername/leaudio" +keywords = ["leaudio", "audio", "networking"] + +[tool.poetry.dependencies] +python = "^3.8" +# Add your dependencies here +# Example: +requests = "^2.25.1" +asyncio = "^3.4.3" + +[tool.poetry.dev-dependencies] +pytest = "^6.2.4" +# Add your development dependencies here + +[build-system] +requires = ["poetry-core>=1.0.0"] +build-backend = "poetry.core.masonry.api" \ No newline at end of file diff --git a/sounds/test0.wav b/sounds/test0.wav new file mode 100644 index 0000000..e5c8878 Binary files /dev/null and b/sounds/test0.wav differ diff --git a/sounds/test1.wav b/sounds/test1.wav new file mode 100644 index 0000000..22846d6 Binary files /dev/null and b/sounds/test1.wav differ diff --git a/utils/le_audio_encoder.py b/utils/le_audio_encoder.py new file mode 100644 index 0000000..bcb69cb --- /dev/null +++ b/utils/le_audio_encoder.py @@ -0,0 +1,121 @@ + +import wasmtime +import ctypes +from typing import List, cast +import wasmtime.loader +import liblc3 # type: ignore +import enum + +store = wasmtime.loader.store + +_memory = cast(wasmtime.Memory, liblc3.memory) + +STACK_POINTER = _memory.data_len(store) + +_memory.grow(store, 1) + +# Mapping wasmtime memory to linear address + +memory = (ctypes.c_ubyte * _memory.data_len(store)).from_address( + + ctypes.addressof(_memory.data_ptr(store).contents) # type: ignore + +) + + +class Liblc3PcmFormat(enum.IntEnum): + + S16 = 0 + + S24 = 1 + + S24_3LE = 2 + + FLOAT = 3 + + +DEFAULT_PCM_SAMPLE_RATE = 48000 +MAX_DECODER_SIZE = liblc3.lc3_decoder_size(10000, DEFAULT_PCM_SAMPLE_RATE) +MAX_ENCODER_SIZE = liblc3.lc3_encoder_size(10000, DEFAULT_PCM_SAMPLE_RATE) + +DECODER_STACK_POINTER = STACK_POINTER +ENCODER_STACK_POINTER = DECODER_STACK_POINTER + MAX_DECODER_SIZE * 2 +DECODE_BUFFER_STACK_POINTER = ENCODER_STACK_POINTER + MAX_ENCODER_SIZE * 2 +ENCODE_BUFFER_STACK_POINTER = DECODE_BUFFER_STACK_POINTER + 8192 + + +DEFAULT_PCM_FORMAT = Liblc3PcmFormat + +DEFAULT_PCM_BYTES_PER_SAMPLE = 2 + + +class LeAudioEncoder: + + def __init__(self): + self.encoders: List[int] = [] + pass + + def setup_encoders(self, sample_rate: int, frame_duration_us: int, num_channels: int) -> None: + """Setup LE audio encoders + + Args: + sample_rate (int): Sample rate in Hz + frame_duration_us (int): Frame duration in microseconds + num_channels (int): Number of channels + """ + self.encoders[:num_channels] = [ + liblc3.lc3_setup_encoder( + frame_duration_us, + sample_rate, + 0, # Input sample rate + ENCODER_STACK_POINTER + MAX_ENCODER_SIZE * i, + ) + for i in range(num_channels) + ] + + def encode( + self, + sdu_length: int, + num_channels: int, + input_stride: int, + input_data: bytes, + ) -> bytes: + """Encode a LE audio frame + + Args: + sdu_length (int): Length of the SDU + num_channels (int): Number of channels + input_stride (int): Stride of the input data + input_data (bytes): Input data to encode + + Returns: + bytes: Encoded data + """ + if not input_data: + return b"" + + input_buffer_offset = ENCODE_BUFFER_STACK_POINTER + input_buffer_size = len(input_data) + + # Copy into wasm memory + memory[input_buffer_offset : input_buffer_offset + input_buffer_size] = input_data + + output_buffer_offset = input_buffer_offset + input_buffer_size + output_buffer_size = sdu_length + output_frame_size = output_buffer_size // num_channels + + for i in range(num_channels): + result = liblc3.lc3_encode( + self.encoders[i], + 0, + input_buffer_offset + DEFAULT_PCM_BYTES_PER_SAMPLE * i, + input_stride, + output_frame_size, + output_buffer_offset + output_frame_size * i, + ) + + if result != 0: + raise RuntimeError(f"lc3_encode failed, result={result}") + + # Extract encoded data from the output buffer + return bytes(memory[output_buffer_offset : output_buffer_offset + output_buffer_size]) diff --git a/utils/liblc3.wasm b/utils/liblc3.wasm new file mode 100644 index 0000000..e905105 Binary files /dev/null and b/utils/liblc3.wasm differ