diff --git a/apps/__init__.py b/apps/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/bap_broadcast_source.py b/apps/bap_broadcast_source.py index c463d8b..b9b9208 100644 --- a/apps/bap_broadcast_source.py +++ b/apps/bap_broadcast_source.py @@ -33,8 +33,7 @@ import numpy as np import pyee import sys -sys.path.append('../utils') -from utils.le_audio_encoder import LeAudioEncoder +from leaudio import LeAudioEncoder from bumble.colors import color from bumble import company_ids @@ -49,6 +48,8 @@ import bumble.device import bumble.transport import bumble.utils +from leaudio import read_wav_file, generate_sine_data + # ----------------------------------------------------------------------------- # Logging @@ -66,30 +67,6 @@ AURACAST_DEFAULT_ATT_MTU = 256 iso_index:int = 0 -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 - - # ----------------------------------------------------------------------------- # Scan For Broadcasts # ----------------------------------------------------------------------------- @@ -747,29 +724,6 @@ async def run_receive( await terminated.wait() -def read_wav_file(filename): - rate, data = wav.read(filename) - num_channels = data.ndim - - if num_channels == 1: - left_channel = data[:] - else: - left_channel = data[:, 1] - - print(len(left_channel)) - upsampled_data = signal.resample(left_channel, int( - 48000 / 41000 * left_channel.shape[0])) - - # wav.write("upsampled_stereo_file.wav", app_specific_codec.sampling_frequency.hz, upsampled_data.astype(data.dtype)) - print("Sample rate:", rate) - print("Number channels:", num_channels) - print("Audio data (left):", left_channel) - print("Bitdepth:", data.dtype.itemsize * 8) - - return upsampled_data.astype(np.int16) - - - async def run_broadcast( transport: str, broadcast_id: int, broadcast_code: str | None, wav_file_path: str ) -> None: @@ -781,25 +735,18 @@ async def run_broadcast( print(color('Periodic advertising not supported', 'red')) return - # encoder = lc3.Encoder( - # frame_duration_us=10000, - # sample_rate_hz=48000, - # num_channels=2, - # input_sample_rate_hz=wav.getframerate(), - # ) - # create snoop file f = open("log.btsnoop", "wb") Snooper = BtSnooper(f) device.host.snooper = Snooper encoder.setup_encoders(48000,10000,1) frames = list[bytes]() - sine = generate_sine_wave_iso_frames(1000,48000,0.01) + sine = generate_sine_data(1000,48000,0.01) print(len(sine)) sample_size = 480 print(wav_file_path) - upsampled_left_channel = read_wav_file(wav_file_path) + upsampled_left_channel = read_wav_file(wav_file_path,48000) num_runs = len(upsampled_left_channel) // sample_size TEST_SINE = 0 for i in range(num_runs): @@ -935,102 +882,12 @@ def run_async(async_command: Coroutine) -> None: # ----------------------------------------------------------------------------- # Main # ----------------------------------------------------------------------------- -@click.group() -@click.pass_context -def auracast(ctx): - ctx.ensure_object(dict) - -@auracast.command('scan') +@click.command() +@click.option('transport','-p', type=str) +@click.option('wav_file_path','-w' , type=str) @click.option( - '--filter-duplicates', is_flag=True, default=False, help='Filter duplicates' -) -@click.option( - '--sync-timeout', - metavar='SYNC_TIMEOUT', - type=float, - default=AURACAST_DEFAULT_SYNC_TIMEOUT, - help='Sync timeout (in seconds)', -) -@click.argument('transport') -@click.pass_context -def scan(ctx, filter_duplicates, sync_timeout, transport): - """Scan for public broadcasts""" - run_async(run_scan(filter_duplicates, sync_timeout, transport)) - - -@auracast.command('assist') -@click.option( - '--broadcast-name', - metavar='BROADCAST_NAME', - help='Broadcast Name to tune to', -) -@click.option( - '--source-id', - metavar='SOURCE_ID', - type=int, - help='Source ID (for remove-source command)', -) -@click.option( - '--command', - type=click.Choice( - ['monitor-state', 'add-source', 'modify-source', 'remove-source'] - ), - required=True, -) -@click.argument('transport') -@click.argument('address') -@click.pass_context -def assist(ctx, broadcast_name, source_id, command, transport, address): - """Scan for broadcasts on behalf of a audio server""" - run_async(run_assist(broadcast_name, source_id, command, transport, address)) - - -@auracast.command('pair') -@click.argument('transport') -@click.argument('address') -@click.pass_context -def pair(ctx, transport, address): - """Pair with an audio server""" - run_async(run_pair(transport, address)) - - -@auracast.command('receive') -@click.argument('transport') -@click.argument('broadcast_id', type=int) -@click.option( - '--broadcast-code', - metavar='BROADCAST_CODE', - type=str, - help='Broadcast encryption code in hex format', -) -@click.option( - '--sync-timeout', - metavar='SYNC_TIMEOUT', - type=float, - default=AURACAST_DEFAULT_SYNC_TIMEOUT, - help='Sync timeout (in seconds)', -) -@click.option( - '--subgroup', - metavar='SUBGROUP', - type=int, - default=0, - help='Index of Subgroup', -) -@click.pass_context -def receive(ctx, transport, broadcast_id, broadcast_code, sync_timeout, subgroup): - """Receive a broadcast source""" - run_async( - run_receive(transport, broadcast_id, broadcast_code, sync_timeout, subgroup) - ) - - -@auracast.command('broadcast') -@click.argument('transport') -@click.argument('wav_file_path', type=str) -@click.option( - '--broadcast-id', + '--broadcast_id', metavar='BROADCAST_ID', type=int, default=123456, @@ -1042,9 +899,10 @@ def receive(ctx, transport, broadcast_id, broadcast_code, sync_timeout, subgroup type=str, help='Broadcast encryption code in hex format', ) -@click.pass_context -def broadcast(ctx, transport, broadcast_id, broadcast_code, wav_file_path): + +def broadcast(transport, broadcast_id, broadcast_code, wav_file_path): """Start a broadcast as a source.""" + #ctx.ensure_object(dict) run_async( run_broadcast( transport=transport, @@ -1056,8 +914,9 @@ def broadcast(ctx, transport, broadcast_id, broadcast_code, wav_file_path): def main(): + logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'ERROR').upper()) - auracast() + broadcast() # ----------------------------------------------------------------------------- @@ -1065,8 +924,9 @@ if __name__ == "__main__": main() # pylint: disable=no-value-for-parameter -# NOTES for the IOT747 -# Baudrate is 9600 +# ####### NOTES for the IOT747 +# Set Baudrate to 9600 +# SCAN 2 OFF 2 # open F0F1F2F3F4F5 BROAD 2 0 1e40 # MUSIC 91 PLAY diff --git a/apps/bap_unicast_client.py b/apps/bap_unicast_client.py index 6714e29..2c74617 100644 --- a/apps/bap_unicast_client.py +++ b/apps/bap_unicast_client.py @@ -38,15 +38,17 @@ from bumble.snoop import BtSnooper import functools from bumble.profiles.ascs import AudioStreamControlServiceProxy from bumble.hci import HCI_IsoDataPacket, HCI_LE_1M_PHY, HCI_LE_2M_PHY -import scipy.io.wavfile as wav + import logging -from utils.le_audio_encoder import LeAudioEncoder +from leaudio import LeAudioEncoder import asyncio import time import os from bumble import hci import sys +from leaudio import generate_sine_data, read_wav_file + app_specific_codec = CodecSpecificConfiguration( sampling_frequency=SamplingFrequency.FREQ_24000, @@ -76,51 +78,8 @@ def clean_reset(com_port): print(f"Error opening or accessing {com_port}: {e}") -def read_wav_file(filename): - - rate, data = wav.read(filename) - num_channels = data.ndim - - if num_channels == 1: - left_channel = data[:] - else: - left_channel = data[:, 1] - - print(len(left_channel)) - 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)) - print("Sample rate:", rate) - print("Number channels:", num_channels) - print("Audio data (left):", left_channel) - print("Bitdepth:", data.dtype.itemsize * 8) - - 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): @@ -452,7 +411,7 @@ async def client() -> None: 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) + upsampled_left_channel = read_wav_file(sound_file,app_specific_codec.sampling_frequency.hz) else: raise FileNotFoundError(f"The file {sound_file} does not exist.") @@ -466,7 +425,7 @@ async def client() -> None: pcm_data = upsampled_left_channel[i * sample_size:i*sample_size+sample_size] else: - pcm_data = generate_sine_wave_iso_frames( + pcm_data = generate_sine_data( 1000, app_specific_codec.sampling_frequency.hz, app_specific_codec.frame_duration.us / 1000000) data = encoder.encode( diff --git a/leaudio/__init__.py b/leaudio/__init__.py index e69de29..e7cd0ff 100644 --- a/leaudio/__init__.py +++ b/leaudio/__init__.py @@ -0,0 +1,2 @@ +from leaudio.encoder import LeAudioEncoder +from leaudio.utils import read_wav_file, generate_sine_data diff --git a/leaudio/encoder.py b/leaudio/encoder.py new file mode 100644 index 0000000..f742fa8 --- /dev/null +++ b/leaudio/encoder.py @@ -0,0 +1,121 @@ + +import wasmtime +import ctypes +from typing import List, cast +import wasmtime.loader +import leaudio.liblc3 as 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/leaudio/liblc3.wasm b/leaudio/liblc3.wasm new file mode 100644 index 0000000..e905105 Binary files /dev/null and b/leaudio/liblc3.wasm differ diff --git a/leaudio/utils.py b/leaudio/utils.py new file mode 100644 index 0000000..b4bcd09 --- /dev/null +++ b/leaudio/utils.py @@ -0,0 +1,49 @@ +from scipy import signal +import scipy.io.wavfile as wav +import numpy as np + +def read_wav_file(filename,target_sample_rate): + + rate, data = wav.read(filename) + num_channels = data.ndim + + if num_channels == 1: + left_channel = data[:] + else: + left_channel = data[:, 1] + + print(len(left_channel)) + upsampled_data = signal.resample(left_channel, int( + target_sample_rate / rate * left_channel.shape[0])) + + # wav.write("upsampled_stereo_file.wav", app_specific_codec.sampling_frequency.hz, upsampled_data.astype(data.dtype)) + print("Sample rate:", rate) + print("Number channels:", num_channels) + print("Audio data (left):", left_channel) + print("Bitdepth:", data.dtype.itemsize * 8) + + return upsampled_data.astype(np.int16) + + +def generate_sine_data(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 diff --git a/pyproject.toml b/pyproject.toml index 154297e..fd8f39a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,19 +8,21 @@ dependencies = ["bumble @ git+https://github.com/markusjellitsch/bumble.git@iso [build-system] -requires = ["setuptools"] +requires = ["setuptools>=61", "wheel", "setuptools_scm>=8"] build-backend = "setuptools.build_meta" -[tool.setuptools.packages.find] -where = ["."] -include = ["leaudio" , "apps", "utils*"] -namespaces = true - -[tool.setuptools.package-dir] -"leaudio" = "." -"leaudio.apps" = "apps" [project.scripts] unicast_client = "leaudio.apps.bap_unicast_client:main" -broadcast_source = "leaudio.apps.bap_broadcast_source:main" \ No newline at end of file +broadcast_source = "leaudio.apps.bap_broadcast_source:main" + +[tool.setuptools] +packages = [ + "leaudio", + "leaudio.apps" +] + +[tool.setuptools.package-dir] +"leaudio" = "leaudio" +"leaudio.apps" = "apps"