refactor package structure and make improvements regarding design

This commit is contained in:
markus
2025-01-11 21:56:36 +01:00
parent 563c38197d
commit 991745ae34
8 changed files with 207 additions and 214 deletions

0
apps/__init__.py Normal file
View File

View File

@@ -33,8 +33,7 @@ import numpy as np
import pyee import pyee
import sys import sys
sys.path.append('../utils') from leaudio import LeAudioEncoder
from utils.le_audio_encoder import LeAudioEncoder
from bumble.colors import color from bumble.colors import color
from bumble import company_ids from bumble import company_ids
@@ -49,6 +48,8 @@ import bumble.device
import bumble.transport import bumble.transport
import bumble.utils import bumble.utils
from leaudio import read_wav_file, generate_sine_data
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Logging # Logging
@@ -66,30 +67,6 @@ AURACAST_DEFAULT_ATT_MTU = 256
iso_index:int = 0 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 # Scan For Broadcasts
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@@ -747,29 +724,6 @@ async def run_receive(
await terminated.wait() 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( async def run_broadcast(
transport: str, broadcast_id: int, broadcast_code: str | None, wav_file_path: str transport: str, broadcast_id: int, broadcast_code: str | None, wav_file_path: str
) -> None: ) -> None:
@@ -781,25 +735,18 @@ async def run_broadcast(
print(color('Periodic advertising not supported', 'red')) print(color('Periodic advertising not supported', 'red'))
return return
# encoder = lc3.Encoder(
# frame_duration_us=10000,
# sample_rate_hz=48000,
# num_channels=2,
# input_sample_rate_hz=wav.getframerate(),
# )
# create snoop file # create snoop file
f = open("log.btsnoop", "wb") f = open("log.btsnoop", "wb")
Snooper = BtSnooper(f) Snooper = BtSnooper(f)
device.host.snooper = Snooper device.host.snooper = Snooper
encoder.setup_encoders(48000,10000,1) encoder.setup_encoders(48000,10000,1)
frames = list[bytes]() frames = list[bytes]()
sine = generate_sine_wave_iso_frames(1000,48000,0.01) sine = generate_sine_data(1000,48000,0.01)
print(len(sine)) print(len(sine))
sample_size = 480 sample_size = 480
print(wav_file_path) 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 num_runs = len(upsampled_left_channel) // sample_size
TEST_SINE = 0 TEST_SINE = 0
for i in range(num_runs): for i in range(num_runs):
@@ -935,102 +882,12 @@ def run_async(async_command: Coroutine) -> None:
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Main # Main
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@click.group()
@click.pass_context
def auracast(ctx):
ctx.ensure_object(dict)
@click.command()
@auracast.command('scan') @click.option('transport','-p', type=str)
@click.option('wav_file_path','-w' , type=str)
@click.option( @click.option(
'--filter-duplicates', is_flag=True, default=False, help='Filter duplicates' '--broadcast_id',
)
@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',
metavar='BROADCAST_ID', metavar='BROADCAST_ID',
type=int, type=int,
default=123456, default=123456,
@@ -1042,9 +899,10 @@ def receive(ctx, transport, broadcast_id, broadcast_code, sync_timeout, subgroup
type=str, type=str,
help='Broadcast encryption code in hex format', 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.""" """Start a broadcast as a source."""
#ctx.ensure_object(dict)
run_async( run_async(
run_broadcast( run_broadcast(
transport=transport, transport=transport,
@@ -1056,8 +914,9 @@ def broadcast(ctx, transport, broadcast_id, broadcast_code, wav_file_path):
def main(): def main():
logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'ERROR').upper()) 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 main() # pylint: disable=no-value-for-parameter
# NOTES for the IOT747 # ####### NOTES for the IOT747
# Baudrate is 9600 # Set Baudrate to 9600
# SCAN 2 OFF 2
# open F0F1F2F3F4F5 BROAD 2 0 1e40 # open F0F1F2F3F4F5 BROAD 2 0 1e40
# MUSIC 91 PLAY # MUSIC 91 PLAY

View File

@@ -38,15 +38,17 @@ from bumble.snoop import BtSnooper
import functools import functools
from bumble.profiles.ascs import AudioStreamControlServiceProxy from bumble.profiles.ascs import AudioStreamControlServiceProxy
from bumble.hci import HCI_IsoDataPacket, HCI_LE_1M_PHY, HCI_LE_2M_PHY from bumble.hci import HCI_IsoDataPacket, HCI_LE_1M_PHY, HCI_LE_2M_PHY
import scipy.io.wavfile as wav
import logging import logging
from utils.le_audio_encoder import LeAudioEncoder from leaudio import LeAudioEncoder
import asyncio import asyncio
import time import time
import os import os
from bumble import hci from bumble import hci
import sys import sys
from leaudio import generate_sine_data, read_wav_file
app_specific_codec = CodecSpecificConfiguration( app_specific_codec = CodecSpecificConfiguration(
sampling_frequency=SamplingFrequency.FREQ_24000, sampling_frequency=SamplingFrequency.FREQ_24000,
@@ -76,51 +78,8 @@ def clean_reset(com_port):
print(f"Error opening or accessing {com_port}: {e}") 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): class Listener(Device.Listener):
@@ -452,7 +411,7 @@ async def client() -> None:
app_specific_codec.frame_duration.us / 1000 / 1000) app_specific_codec.frame_duration.us / 1000 / 1000)
if TEST_SINE == 0: if TEST_SINE == 0:
if os.path.isfile(sound_file): 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: else:
raise FileNotFoundError(f"The file {sound_file} does not exist.") raise FileNotFoundError(f"The file {sound_file} does not exist.")
@@ -466,7 +425,7 @@ async def client() -> None:
pcm_data = upsampled_left_channel[i * pcm_data = upsampled_left_channel[i *
sample_size:i*sample_size+sample_size] sample_size:i*sample_size+sample_size]
else: 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) 1000, app_specific_codec.sampling_frequency.hz, app_specific_codec.frame_duration.us / 1000000)
data = encoder.encode( data = encoder.encode(

View File

@@ -0,0 +1,2 @@
from leaudio.encoder import LeAudioEncoder
from leaudio.utils import read_wav_file, generate_sine_data

121
leaudio/encoder.py Normal file
View File

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

BIN
leaudio/liblc3.wasm Normal file

Binary file not shown.

49
leaudio/utils.py Normal file
View File

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

View File

@@ -8,19 +8,21 @@ dependencies = ["bumble @ git+https://github.com/markusjellitsch/bumble.git@iso
[build-system] [build-system]
requires = ["setuptools"] requires = ["setuptools>=61", "wheel", "setuptools_scm>=8"]
build-backend = "setuptools.build_meta" 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] [project.scripts]
unicast_client = "leaudio.apps.bap_unicast_client:main" unicast_client = "leaudio.apps.bap_unicast_client:main"
broadcast_source = "leaudio.apps.bap_broadcast_source:main" broadcast_source = "leaudio.apps.bap_broadcast_source:main"
[tool.setuptools]
packages = [
"leaudio",
"leaudio.apps"
]
[tool.setuptools.package-dir]
"leaudio" = "leaudio"
"leaudio.apps" = "apps"