From 55d3fd90f551f2c9161a1ebbc26c5fdf3b5a246d Mon Sep 17 00:00:00 2001 From: Gilles Boccon-Gibod Date: Fri, 24 Jan 2025 16:06:16 -0500 Subject: [PATCH 01/13] wip --- apps/auracast.py | 809 +++++++++++++++++++++--- bumble/profiles/le_audio.py | 66 +- docs/mkdocs/src/apps_and_tools/index.md | 15 +- pyproject.toml | 3 +- 4 files changed, 810 insertions(+), 83 deletions(-) diff --git a/apps/auracast.py b/apps/auracast.py index 739d7f1c..9269e153 100644 --- a/apps/auracast.py +++ b/apps/auracast.py @@ -17,15 +17,31 @@ # ----------------------------------------------------------------------------- from __future__ import annotations +import abc import asyncio +import asyncio.subprocess +from concurrent.futures import ThreadPoolExecutor import contextlib import dataclasses +import enum import functools import logging import os +import pathlib import wave -import itertools -from typing import cast, Any, AsyncGenerator, Coroutine, Dict, Optional, Tuple +import struct +import sys +import time +from typing import ( + cast, + Any, + AsyncGenerator, + BinaryIO, + Coroutine, + Optional, + Tuple, + TYPE_CHECKING, +) import click import pyee @@ -33,7 +49,7 @@ import pyee try: import lc3 # type: ignore # pylint: disable=E0401 except ImportError as e: - raise ImportError("Try `python -m pip install \".[lc3]\"`.") from e + raise ImportError("Try `python -m pip install \".[auracast]\"`.") from e from bumble.colors import color from bumble import company_ids @@ -48,6 +64,8 @@ import bumble.device import bumble.transport import bumble.utils +if TYPE_CHECKING: + import sounddevice # ----------------------------------------------------------------------------- # Logging @@ -62,6 +80,502 @@ AURACAST_DEFAULT_DEVICE_NAME = 'Bumble Auracast' AURACAST_DEFAULT_DEVICE_ADDRESS = hci.Address('F0:F1:F2:F3:F4:F5') AURACAST_DEFAULT_SYNC_TIMEOUT = 5.0 AURACAST_DEFAULT_ATT_MTU = 256 +AURACAST_DEFAULT_FRAME_DURATION = 10000 +AURACAST_DEFAULT_SAMPLE_RATE = 48000 + + +# ----------------------------------------------------------------------------- +# Audio I/O Support +# ----------------------------------------------------------------------------- +@dataclasses.dataclass +class PcmFormat: + class Endianness(enum.Enum): + LITTLE = 0 + BIG = 1 + + class SampleType(enum.Enum): + FLOAT32 = 0 + INT16 = 1 + + endianness: Endianness + sample_type: SampleType + sample_rate: int + channels: int + + @classmethod + def from_str(cls, format_str: str) -> PcmFormat: + endianness = cls.Endianness.LITTLE # Others not yet supported. + sample_type_str, sample_rate_str, channels_str = format_str.split(',') + if sample_type_str == 'int16le': + sample_type = cls.SampleType.INT16 + elif sample_rate_str == 'float32le': + sample_type = cls.SampleType.FLOAT32 + else: + raise ValueError(f'sample type {sample_type_str} not supported') + sample_rate = int(sample_rate_str) + channels = int(channels_str) + + return cls(endianness, sample_type, sample_rate, channels) + + +def check_audio_output(output: str) -> bool: + if output == 'device' or output.startswith('device:'): + try: + import sounddevice + except ImportError as exc: + raise ValueError( + 'audio output not available (sounddevice python module not installed)' + ) from exc + + if output == 'device': + # Default device + return True + + # Specific device + device = output[7:] + if device == '?': + print(color('Audio Devices:', 'yellow')) + for device_info in [ + device_info + for device_info in sounddevice.query_devices() + if device_info['max_output_channels'] > 0 + ]: + device_index = device_info['index'] + is_default = ( + color(' [default]', 'green') + if sounddevice.default.device[1] == device_index + else '' + ) + print( + f'{color(device_index, "cyan")}: {device_info["name"]}{is_default}' + ) + return False + + try: + device_info = sounddevice.query_devices(int(device)) + except sounddevice.PortAudioError as exc: + raise ValueError('No such audio device') from exc + + if device_info['max_output_channels'] < 1: + raise ValueError( + f'Device {device} ({device_info["name"]}) does not have an output' + ) + + return True + + +async def create_audio_output(output: str) -> AudioOutput: + if output == 'stdout': + return StreamAudioOutput(sys.stdout.buffer) + + if output == 'device' or output.startswith('device:'): + device_name = '' if output == 'device' else output[7:] + return SoundDeviceAudioOutput(device_name) + + if output == 'ffplay': + return SubprocessAudioOutput( + command=( + 'ffplay -probesize 32 -fflags nobuffer -analyzeduration 0 ' + '-ar {sample_rate} ' + '-ch_layout {channel_layout} ' + '-f f32le pipe:0' + ) + ) + + if output.startswith('file:'): + return FileAudioOutput(output[5:]) + + raise ValueError('unsupported audio output') + + +class AudioOutput(abc.ABC): + """Audio output to which PCM samples can be written.""" + + async def open(self, pcm_format: PcmFormat) -> None: + """Start the output.""" + + @abc.abstractmethod + def write(self, pcm_samples: bytes) -> None: + """Write PCM samples. Must not block.""" + + async def aclose(self) -> None: + """Close the output.""" + + +class ThreadedAudioOutput(AudioOutput): + """Base class for AudioOutput classes that may need to call blocking functions. + + The actual writing is performed in a thread, so as to ensure that calling write() + does not block the caller. + """ + + def __init__(self) -> None: + self._thread_pool = ThreadPoolExecutor(1) + self._pcm_samples: asyncio.Queue[bytes] = asyncio.Queue() + self._write_task = asyncio.create_task(self._write_loop()) + + async def _write_loop(self) -> None: + while True: + pcm_samples = await self._pcm_samples.get() + await asyncio.get_running_loop().run_in_executor( + self._thread_pool, self._write, pcm_samples + ) + + @abc.abstractmethod + def _write(self, pcm_samples: bytes) -> None: + """This method does the actual writing and can block.""" + + def write(self, pcm_samples: bytes) -> None: + self._pcm_samples.put_nowait(pcm_samples) + + def _close(self) -> None: + """This method does the actual closing and can block.""" + + async def aclose(self) -> None: + await asyncio.get_running_loop().run_in_executor(self._thread_pool, self._close) + self._write_task.cancel() + self._thread_pool.shutdown() + + +class SoundDeviceAudioOutput(ThreadedAudioOutput): + def __init__(self, device_name: str) -> None: + super().__init__() + self._device = int(device_name) if device_name else None + self._stream: sounddevice.RawOutputStream | None = None + + async def open(self, pcm_format: PcmFormat) -> None: + import sounddevice + + self._stream = sounddevice.RawOutputStream( + samplerate=pcm_format.sample_rate, + device=self._device, + channels=pcm_format.channels, + dtype='float32', + ) + self._stream.start() + + def _write(self, pcm_samples: bytes) -> None: + if self._stream is None: + return + + try: + self._stream.write(pcm_samples) + except Exception as error: + print(f'Sound device error: {error}') + raise + + def _close(self): + self._stream.stop() + self._stream = None + + +class StreamAudioOutput(ThreadedAudioOutput): + """AudioOutput where PCM samples are written to a stream that may block.""" + + def __init__(self, stream: BinaryIO) -> None: + super().__init__() + self._stream = stream + + def _write(self, pcm_samples: bytes) -> None: + self._stream.write(pcm_samples) + self._stream.flush() + + +class FileAudioOutput(StreamAudioOutput): + """AudioOutput where PCM samples are written to a file.""" + + def __init__(self, filename: str) -> None: + self._file = open(filename, "wb") + super().__init__(self._file) + + async def shutdown(self): + self._file.close() + return await super().shutdown() + + +class SubprocessAudioOutput(AudioOutput): + """AudioOutput where audio samples are written to a subprocess via stdin.""" + + def __init__(self, command: str) -> None: + self._command = command + self._subprocess: asyncio.subprocess.Process | None + + async def open(self, pcm_format: PcmFormat) -> None: + if pcm_format.channels == 1: + channel_layout = 'mono' + elif pcm_format.channels == 2: + channel_layout = 'stereo' + else: + raise ValueError(f'{pcm_format.channels} channels not supported') + + command = self._command.format( + sample_rate=pcm_format.sample_rate, channel_layout=channel_layout + ) + self._subprocess = await asyncio.create_subprocess_shell( + command, + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + + def write(self, pcm_samples: bytes) -> None: + if self._subprocess is None or self._subprocess.stdin is None: + return + + self._subprocess.stdin.write(pcm_samples) + + async def aclose(self): + if self._subprocess: + self._subprocess.terminate() + + +def check_audio_input(input: str) -> bool: + if input == 'device' or input.startswith('device:'): + try: + import sounddevice + except ImportError as exc: + raise ValueError( + 'audio input not available (sounddevice python module not installed)' + ) from exc + + if input == 'device': + # Default device + return True + + # Specific device + device = input[7:] + if device == '?': + print(color('Audio Devices:', 'yellow')) + for device_info in [ + device_info + for device_info in sounddevice.query_devices() + if device_info['max_input_channels'] > 0 + ]: + device_index = device_info["index"] + is_mono = device_info['max_input_channels'] == 1 + max_channels = color(f'[{"mono" if is_mono else "stereo"}]', 'cyan') + is_default = ( + color(' [default]', 'green') + if sounddevice.default.device[0] == device_index + else '' + ) + print( + f'{color(device_index, "cyan")}: {device_info["name"]}' + f' {max_channels}{is_default}' + ) + return False + + try: + device_info = sounddevice.query_devices(int(device)) + except sounddevice.PortAudioError as exc: + raise ValueError('No such audio device') from exc + + if device_info['max_input_channels'] < 1: + raise ValueError( + f'Device {device} ({device_info["name"]}) does not have an input' + ) + + return True + + +async def create_audio_input(input: str, input_format: str) -> AudioInput: + pcm_format: PcmFormat | None + if input_format == 'auto': + pcm_format = None + else: + pcm_format = PcmFormat.from_str(input_format) + + if input == 'stdin': + if not pcm_format: + raise ValueError('input format details required for stdin') + return StreamAudioInput(sys.stdin.buffer, pcm_format) + + if input == 'device' or input.startswith('device:'): + if not pcm_format: + raise ValueError('input format details required for device') + device_name = '' if input == 'device' else input[7:] + return SoundDeviceAudioInput(device_name, pcm_format) + + # If there's no file: prefix, check if we can assume it is a file. + if pathlib.Path(input).is_file(): + input = 'file:' + input + + if input.startswith('file:'): + filename = input[5:] + if filename.endswith('.wav'): + if input_format != 'auto': + raise ValueError(".wav file only supported with 'auto' format") + return WaveAudioInput(filename) + + if pcm_format is None: + raise ValueError('input format details required for raw PCM files') + return FileAudioInput(filename, pcm_format) + + raise ValueError('input not supported') + + +class AudioInput(abc.ABC): + """Audio input that produces PCM samples.""" + + @abc.abstractmethod + async def open(self) -> PcmFormat: + """Open the input.""" + + @abc.abstractmethod + def frames(self, frame_size: int) -> AsyncGenerator[bytes]: + """Generate one frame of PCM samples. Must not block.""" + + async def aclose(self) -> None: + """Close the input.""" + + +class ThreadedAudioInput(AudioInput): + """Base class for AudioInput implementation where reading samples may block.""" + + def __init__(self) -> None: + self._thread_pool = ThreadPoolExecutor(1) + self._pcm_samples: asyncio.Queue[bytes] = asyncio.Queue() + + @abc.abstractmethod + def _read(self, frame_size: int) -> bytes: + pass + + @abc.abstractmethod + def _open(self) -> PcmFormat: + pass + + def _close(self) -> None: + pass + + async def open(self) -> PcmFormat: + return await asyncio.get_running_loop().run_in_executor( + self._thread_pool, self._open + ) + + async def frames(self, frame_size: int) -> AsyncGenerator[bytes]: + while pcm_sample := await asyncio.get_running_loop().run_in_executor( + self._thread_pool, self._read, frame_size + ): + yield pcm_sample + + async def aclose(self) -> None: + await asyncio.get_running_loop().run_in_executor(self._thread_pool, self._close) + self._thread_pool.shutdown() + + +class WaveAudioInput(ThreadedAudioInput): + """Audio input that reads PCM samples from a .wav file.""" + + def __init__(self, filename: str) -> None: + super().__init__() + self._filename = filename + self._wav: wave.Wave_read | None = None + self._bytes_read = 0 + + def _open(self) -> PcmFormat: + self._wav = wave.open(self._filename, 'rb') + if self._wav.getsampwidth() != 2: + raise ValueError('sample width not supported') + return PcmFormat( + PcmFormat.Endianness.LITTLE, + PcmFormat.SampleType.INT16, + self._wav.getframerate(), + self._wav.getnchannels(), + ) + + def _read(self, frame_size: int) -> bytes: + if not self._wav: + return b'' + + pcm_samples = self._wav.readframes(frame_size) + if not pcm_samples and self._bytes_read: + # Loop around. + self._wav.rewind() + self._bytes_read = 0 + pcm_samples = self._wav.readframes(frame_size) + + self._bytes_read += len(pcm_samples) + return pcm_samples + + def _close(self) -> None: + if self._wav: + self._wav.close() + + +class StreamAudioInput(ThreadedAudioInput): + """AudioInput where samples are read from a raw PCM stream that may block.""" + + def __init__(self, stream: BinaryIO, pcm_format: PcmFormat) -> None: + super().__init__() + self._stream = stream + self._pcm_format = pcm_format + + def _open(self) -> PcmFormat: + return self._pcm_format + + def _read(self, frame_size: int) -> bytes: + return self._stream.read(frame_size * self._pcm_format.channels * 2) + + +class FileAudioInput(StreamAudioInput): + """AudioInput where PCM samples are read from a raw PCM file.""" + + def __init__(self, filename: str, pcm_format: PcmFormat) -> None: + self._stream = open(filename, "rb") + super().__init__(self._stream, pcm_format) + + def _read(self, frame_size: int) -> bytes: + return self._stream.read(frame_size * self._pcm_format.channels * 2) + + def _close(self) -> None: + self._stream.close() + + +class SoundDeviceAudioInput(ThreadedAudioInput): + def __init__(self, device_name: str, pcm_format: PcmFormat) -> None: + super().__init__() + self._device = int(device_name) if device_name else None + self._pcm_format = pcm_format + self._stream: sounddevice.RawInputStream | None = None + + def _open(self) -> PcmFormat: + import sounddevice + + self._stream = sounddevice.RawInputStream( + samplerate=self._pcm_format.sample_rate, + device=self._device, + channels=self._pcm_format.channels, + dtype='int16', + ) + self._stream.start() + + return PcmFormat( + PcmFormat.Endianness.LITTLE, + PcmFormat.SampleType.INT16, + self._pcm_format.sample_rate, + 2, + ) + + def _read(self, frame_size: int) -> bytes: + if not self._stream: + return b'' + pcm_buffer, overflowed = self._stream.read(frame_size) + if overflowed: + logger.warning("input overflow") + + # Convert the buffer to stereo if needed + if self._pcm_format.channels == 1: + stereo_buffer = bytearray() + for i in range(frame_size): + sample = pcm_buffer[i * 2 : i * 2 + 2] + stereo_buffer += sample + sample + return stereo_buffer + + return bytes(pcm_buffer) + + def _close(self): + self._stream.stop() + self._stream = None # ----------------------------------------------------------------------------- @@ -625,11 +1139,20 @@ async def run_pair(transport: str, address: str) -> None: async def run_receive( transport: str, - broadcast_id: int, + broadcast_id: Optional[int], + output: str, broadcast_code: str | None, sync_timeout: float, subgroup_index: int, ) -> None: + # Run a pre-flight check for the output. + try: + if not check_audio_output(output): + return + except ValueError as error: + print(error) + return + async with create_device(transport) as device: if not device.supports_le_periodic_advertising: print(color('Periodic advertising not supported', 'red')) @@ -643,7 +1166,7 @@ async def run_receive( def on_new_broadcast(broadcast: BroadcastScanner.Broadcast) -> None: if scan_result.done(): return - if broadcast.broadcast_id == broadcast_id: + if broadcast_id is None or broadcast.broadcast_id == broadcast_id: scan_result.set_result(broadcast) scanner.on('new_broadcast', on_new_broadcast) @@ -695,56 +1218,69 @@ async def run_receive( num_channels=num_bis, ) sdus = [b''] * num_bis - subprocess = await asyncio.create_subprocess_shell( - f'stdbuf -i0 ffplay -ar {sampling_frequency.hz} -ac {num_bis} -f f32le pipe:0', - stdin=asyncio.subprocess.PIPE, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - ) - for i, bis_link in enumerate(big_sync.bis_links): - print(f'Setup ISO for BIS {bis_link.handle}') - def sink(index: int, packet: hci.HCI_IsoDataPacket): - nonlocal sdus - sdus[index] = packet.iso_sdu_fragment - if all(sdus) and subprocess.stdin: - subprocess.stdin.write(decoder.decode(b''.join(sdus)).tobytes()) - sdus = [b''] * num_bis - - bis_link.sink = functools.partial(sink, i) - await bis_link.setup_data_path( - direction=bis_link.Direction.CONTROLLER_TO_HOST + async with contextlib.aclosing( + await create_audio_output(output) + ) as audio_output: + await audio_output.open( + PcmFormat( + PcmFormat.Endianness.LITTLE, + PcmFormat.SampleType.FLOAT32, + sampling_frequency.hz, + num_bis, + ) ) - terminated = asyncio.Event() - big_sync.on(big_sync.Event.TERMINATION, lambda _: terminated.set()) - await terminated.wait() + for i, bis_link in enumerate(big_sync.bis_links): + print(f'Setup ISO for BIS {bis_link.handle}') + + def sink(index: int, packet: hci.HCI_IsoDataPacket): + nonlocal sdus + sdus[index] = packet.iso_sdu_fragment + if all(sdus): + audio_output.write(decoder.decode(b''.join(sdus)).tobytes()) + sdus = [b''] * num_bis + + bis_link.sink = functools.partial(sink, i) + await device.send_command( + hci.HCI_LE_Setup_ISO_Data_Path_Command( + connection_handle=bis_link.handle, + data_path_direction=hci.HCI_LE_Setup_ISO_Data_Path_Command.Direction.CONTROLLER_TO_HOST, + data_path_id=0, + codec_id=hci.CodingFormat(codec_id=hci.CodecID.TRANSPARENT), + controller_delay=0, + codec_configuration=b'', + ), + check_result=True, + ) + + terminated = asyncio.Event() + big_sync.on(big_sync.Event.TERMINATION, lambda _: terminated.set()) + await terminated.wait() 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, + broadcast_name: str, + manufacturer_data: tuple[int, bytes] | None, + input: str, + input_format: str, ) -> None: + # Run a pre-flight check for the input. + try: + if not check_audio_input(input): + return + except ValueError as error: + print(error) + return + async with create_device(transport) as device: if not device.supports_le_periodic_advertising: print(color('Periodic advertising not supported', 'red')) return - with wave.open(wav_file_path, 'rb') as wav: - print('Encoding wav file into lc3...') - encoder = lc3.Encoder( - frame_duration_us=10000, - sample_rate_hz=48000, - num_channels=2, - input_sample_rate_hz=wav.getframerate(), - ) - frames = list[bytes]() - while pcm := wav.readframes(encoder.get_frame_samples()): - frames.append( - encoder.encode(pcm, num_bytes=200, bit_depth=wav.getsampwidth() * 8) - ) - del encoder - print('Encoding complete.') - basic_audio_announcement = bap.BasicAudioAnnouncement( presentation_delay=40000, subgroups=[ @@ -783,7 +1319,23 @@ async def run_broadcast( ], ) broadcast_audio_announcement = bap.BroadcastAudioAnnouncement(broadcast_id) - print('Start Advertising') + + advertising_manufacturer_data = ( + b'' + if manufacturer_data is None + else bytes( + core.AdvertisingData( + [ + ( + core.AdvertisingData.MANUFACTURER_SPECIFIC_DATA, + struct.pack('= now: + await asyncio.sleep(target_time - now) - for frame in itertools.cycle(frames): - mid = len(frame) // 2 - big.bis_links[0].write(frame[:mid]) - big.bis_links[1].write(frame[mid:]) - await asyncio.sleep(0.009) + lc3_frame = encoder.encode(pcm_frame, num_bytes=200, bit_depth=16) + + mid = len(lc3_frame) // 2 + big.bis_links[0].write(lc3_frame[:mid]) + big.bis_links[1].write(lc3_frame[mid:]) + + frame_count += 1 def run_async(async_command: Coroutine) -> None: @@ -917,7 +1493,7 @@ def scan(ctx, filter_duplicates, sync_timeout, 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""" + """Scan for broadcasts on behalf of an audio server""" run_async(run_assist(broadcast_name, source_id, command, transport, address)) @@ -932,7 +1508,24 @@ def pair(ctx, transport, address): @auracast.command('receive') @click.argument('transport') -@click.argument('broadcast_id', type=int) +@click.argument( + 'broadcast_id', + type=int, + required=False, +) +@click.option( + '--output', + default='device', + help=( + "Audio output. " + "'device' -> use the host's default sound output device, " + "'device:' -> use one of the host's sound output device " + "(specify 'device:?' to get a list of available sound output devices), " + "'stdout' -> send audio to stdout, " + "'file: -> write audio to a raw float32 PCM file, " + "'ffplay' -> pipe the audio to ffplay" + ), +) @click.option( '--broadcast-code', metavar='BROADCAST_CODE', @@ -954,16 +1547,56 @@ def pair(ctx, transport, address): help='Index of Subgroup', ) @click.pass_context -def receive(ctx, transport, broadcast_id, broadcast_code, sync_timeout, subgroup): +def receive( + ctx, + transport, + broadcast_id, + output, + broadcast_code, + sync_timeout, + subgroup, +): """Receive a broadcast source""" run_async( - run_receive(transport, broadcast_id, broadcast_code, sync_timeout, subgroup) + run_receive( + transport, + broadcast_id, + output, + broadcast_code, + sync_timeout, + subgroup, + ) ) @auracast.command('broadcast') @click.argument('transport') -@click.argument('wav_file_path', type=str) +@click.option( + '--input', + required=True, + help=( + "Audio input. " + "'device' -> use the host's default sound input device, " + "'device:' -> use one of the host's sound input devices " + "(specify 'device:?' to get a list of available sound input devices), " + "'stdin' -> receive audio from stdin as int16 PCM, " + "'file: -> read audio from a .wav or raw int16 PCM file. " + "(The file: prefix may be ommitted if the file path does not start with " + "the substring 'device:' or 'file:' and is not 'stdin')" + ), +) +@click.option( + '--input-format', + metavar="FORMAT", + default='auto', + help=( + "Audio input format. " + "Use 'auto' for .wav files, or for the default setting with the devices. " + "For other inputs, the format is specified as " + ",, (supported : 'int16le' " + "for 16 bit signed integers with little-endian byte order)" + ), +) @click.option( '--broadcast-id', metavar='BROADCAST_ID', @@ -974,18 +1607,52 @@ def receive(ctx, transport, broadcast_id, broadcast_code, sync_timeout, subgroup @click.option( '--broadcast-code', metavar='BROADCAST_CODE', - type=str, help='Broadcast encryption code in hex format', ) +@click.option( + '--broadcast-name', + metavar='BROADCAST_NAME', + default='Bumble Auracast', + help='Broadcast name', +) +@click.option( + '--manufacturer-data', + metavar='VENDOR-ID:DATA-HEX', + help='Manufacturer data (specify as :)', +) @click.pass_context -def broadcast(ctx, transport, broadcast_id, broadcast_code, wav_file_path): +def broadcast( + ctx, + transport, + broadcast_id, + broadcast_code, + manufacturer_data, + broadcast_name, + input, + input_format, +): """Start a broadcast as a source.""" + if manufacturer_data: + vendor_id_str, data_hex = manufacturer_data.split(':') + vendor_id = int(vendor_id_str) + data = bytes.fromhex(data_hex) + manufacturer_data_tuple = (vendor_id, data) + else: + manufacturer_data_tuple = None + + if (input == 'device' or input.startswith('device:')) and input_format == 'auto': + # Use a default format for device inputs + input_format = 'int16le,48000,1' + run_async( run_broadcast( transport=transport, broadcast_id=broadcast_id, broadcast_code=broadcast_code, - wav_file_path=wav_file_path, + broadcast_name=broadcast_name, + manufacturer_data=manufacturer_data_tuple, + input=input, + input_format=input_format, ) ) diff --git a/bumble/profiles/le_audio.py b/bumble/profiles/le_audio.py index b152fd97..9b8ba3d8 100644 --- a/bumble/profiles/le_audio.py +++ b/bumble/profiles/le_audio.py @@ -18,22 +18,32 @@ from __future__ import annotations import dataclasses import struct -from typing import List, Type +from typing import Any, List, Type from typing_extensions import Self +from bumble.profiles import bap from bumble import utils # ----------------------------------------------------------------------------- # Classes # ----------------------------------------------------------------------------- +class AudioActiveState(utils.OpenIntEnum): + NO_AUDIO_DATA_TRANSMITTED = 0x00 + AUDIO_DATA_TRANSMITTED = 0x01 + + +class AssistedListeningStream(utils.OpenIntEnum): + UNSPECIFIED_AUDIO_ENHANCEMENT = 0x00 + + @dataclasses.dataclass class Metadata: '''Bluetooth Assigned Numbers, Section 6.12.6 - Metadata LTV structures. - As Metadata fields may extend, and Spec doesn't forbid duplication, we don't parse - Metadata into a key-value style dataclass here. Rather, we encourage users to parse - again outside the lib. + As Metadata fields may extend, and Spec doesn't forbid duplication, we don't + automatically parse the Metadata data into specific classes. Callers may decode + the data by themselves, or use the Entry.decode method. ''' class Tag(utils.OpenIntEnum): @@ -57,6 +67,44 @@ class Metadata: tag: Metadata.Tag data: bytes + def decode(self) -> Any: + """ + Decode the data into an object, if possible. + + If no specific object class exists to represent the data, the raw data + bytes are returned. + """ + + if self.tag in ( + Metadata.Tag.PREFERRED_AUDIO_CONTEXTS, + Metadata.Tag.STREAMING_AUDIO_CONTEXTS, + ): + return bap.ContextType(struct.unpack(" Self: return cls(tag=Metadata.Tag(data[0]), data=data[1:]) @@ -81,3 +129,13 @@ class Metadata: def __bytes__(self) -> bytes: return b''.join([bytes(entry) for entry in self.entries]) + + def __str__(self) -> str: + entries_str = [] + for entry in self.entries: + decoded = entry.decode() + entries_str.append( + f'{entry.tag.name}: ' + f'{decoded.hex() if isinstance(decoded, bytes) else decoded!r}' + ) + return f'Metadata(entries={", ".join(entry_str for entry_str in entries_str)})' diff --git a/docs/mkdocs/src/apps_and_tools/index.md b/docs/mkdocs/src/apps_and_tools/index.md index 0c2b4d5e..e99a0187 100644 --- a/docs/mkdocs/src/apps_and_tools/index.md +++ b/docs/mkdocs/src/apps_and_tools/index.md @@ -4,12 +4,13 @@ APPS & TOOLS Included in the project are a few apps and tools, built on top of the core libraries. These include: - * [Console](console.md) - an interactive text-based console - * [Bench](bench.md) - Speed and Latency benchmarking between two devices (LE and Classic) - * [Pair](pair.md) - Pair/bond two devices (LE and Classic) - * [Unbond](unbond.md) - Remove a previously established bond - * [HCI Bridge](hci_bridge.md) - a HCI transport bridge to connect two HCI transports and filter/snoop the HCI packets - * [Golden Gate Bridge](gg_bridge.md) - a bridge between GATT and UDP to use with the Golden Gate "stack tool" - * [Show](show.md) - Parse a file with HCI packets and print the details of each packet in a human readable form + * [Auracast](auracast.md) - Commands to broadcast, receive and/or control LE Audio. + * [Console](console.md) - An interactive text-based console. + * [Bench](bench.md) - Speed and Latency benchmarking between two devices (LE and Classic). + * [Pair](pair.md) - Pair/bond two devices (LE and Classic). + * [Unbond](unbond.md) - Remove a previously established bond. + * [HCI Bridge](hci_bridge.md) - An HCI transport bridge to connect two HCI transports and filter/snoop the HCI packets. + * [Golden Gate Bridge](gg_bridge.md) - Bridge between GATT and UDP to use with the Golden Gate "stack tool". + * [Show](show.md) - Parse a file with HCI packets and print the details of each packet in a human readable form. * [Speaker](speaker.md) - Virtual Bluetooth speaker, with a command line and browser-based UI. * [Link Relay](link_relay.md) - WebSocket relay for virtual RemoteLink instances to communicate with each other. diff --git a/pyproject.toml b/pyproject.toml index 70385514..504c2ddc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -65,8 +65,9 @@ documentation = [ "mkdocs-material >= 8.5.6", "mkdocstrings[python] >= 0.19.0", ] -lc3 = [ +auracast = [ "lc3 @ git+https://github.com/google/liblc3.git", + "sounddevice >= 0.5.1", ] [project.scripts] From 70141c04398c75351bf0b20c56291c68a2f3aa72 Mon Sep 17 00:00:00 2001 From: Gilles Boccon-Gibod Date: Mon, 3 Feb 2025 17:58:09 -0500 Subject: [PATCH 02/13] improvements --- apps/auracast.py | 583 +++++++-------------------- apps/device_info.py | 54 ++- bumble/device.py | 45 +++ bumble/gatt.py | 2 - bumble/gatt_client.py | 16 +- bumble/profiles/aics.py | 57 +-- bumble/profiles/asha.py | 10 +- bumble/profiles/bass.py | 29 +- bumble/profiles/gmap.py | 11 +- bumble/profiles/le_audio.py | 31 +- bumble/profiles/pacs.py | 64 ++- bumble/profiles/tmap.py | 12 +- bumble/profiles/{vcp.py => vcs.py} | 65 +-- bumble/profiles/vocs.py | 46 +-- docs/mkdocs/mkdocs.yml | 6 +- docs/mkdocs/src/getting_started.md | 24 +- docs/mkdocs/src/platforms/android.md | 12 +- examples/run_vcp_renderer.py | 12 +- pyproject.toml | 9 +- tests/aics_test.py | 2 +- tests/vcp_test.py | 52 +-- tests/vocs_test.py | 2 +- 22 files changed, 495 insertions(+), 649 deletions(-) rename bumble/profiles/{vcp.py => vcs.py} (85%) diff --git a/apps/auracast.py b/apps/auracast.py index 9269e153..9ba84698 100644 --- a/apps/auracast.py +++ b/apps/auracast.py @@ -17,30 +17,25 @@ # ----------------------------------------------------------------------------- from __future__ import annotations -import abc import asyncio import asyncio.subprocess -from concurrent.futures import ThreadPoolExecutor +import collections import contextlib import dataclasses -import enum import functools import logging import os import pathlib -import wave import struct import sys -import time from typing import ( cast, Any, AsyncGenerator, - BinaryIO, Coroutine, + Deque, Optional, Tuple, - TYPE_CHECKING, ) import click @@ -49,8 +44,11 @@ import pyee try: import lc3 # type: ignore # pylint: disable=E0401 except ImportError as e: - raise ImportError("Try `python -m pip install \".[auracast]\"`.") from e + raise ImportError( + "Try `python -m pip install \"git+https://github.com/google/liblc3.git\"`." + ) from e +from bumble.audio import io as audio_io from bumble.colors import color from bumble import company_ids from bumble import core @@ -64,9 +62,6 @@ import bumble.device import bumble.transport import bumble.utils -if TYPE_CHECKING: - import sounddevice - # ----------------------------------------------------------------------------- # Logging # ----------------------------------------------------------------------------- @@ -82,46 +77,16 @@ AURACAST_DEFAULT_SYNC_TIMEOUT = 5.0 AURACAST_DEFAULT_ATT_MTU = 256 AURACAST_DEFAULT_FRAME_DURATION = 10000 AURACAST_DEFAULT_SAMPLE_RATE = 48000 +AURACAST_DEFAULT_TRANSMIT_BITRATE = 80000 # ----------------------------------------------------------------------------- # Audio I/O Support # ----------------------------------------------------------------------------- -@dataclasses.dataclass -class PcmFormat: - class Endianness(enum.Enum): - LITTLE = 0 - BIG = 1 - - class SampleType(enum.Enum): - FLOAT32 = 0 - INT16 = 1 - - endianness: Endianness - sample_type: SampleType - sample_rate: int - channels: int - - @classmethod - def from_str(cls, format_str: str) -> PcmFormat: - endianness = cls.Endianness.LITTLE # Others not yet supported. - sample_type_str, sample_rate_str, channels_str = format_str.split(',') - if sample_type_str == 'int16le': - sample_type = cls.SampleType.INT16 - elif sample_rate_str == 'float32le': - sample_type = cls.SampleType.FLOAT32 - else: - raise ValueError(f'sample type {sample_type_str} not supported') - sample_rate = int(sample_rate_str) - channels = int(channels_str) - - return cls(endianness, sample_type, sample_rate, channels) - - def check_audio_output(output: str) -> bool: if output == 'device' or output.startswith('device:'): try: - import sounddevice + import sounddevice # type: ignore[import-untyped] except ImportError as exc: raise ValueError( 'audio output not available (sounddevice python module not installed)' @@ -164,16 +129,16 @@ def check_audio_output(output: str) -> bool: return True -async def create_audio_output(output: str) -> AudioOutput: +async def create_audio_output(output: str) -> audio_io.AudioOutput: if output == 'stdout': - return StreamAudioOutput(sys.stdout.buffer) + return audio_io.StreamAudioOutput(sys.stdout.buffer) if output == 'device' or output.startswith('device:'): device_name = '' if output == 'device' else output[7:] - return SoundDeviceAudioOutput(device_name) + return audio_io.SoundDeviceAudioOutput(device_name) if output == 'ffplay': - return SubprocessAudioOutput( + return audio_io.SubprocessAudioOutput( command=( 'ffplay -probesize 32 -fflags nobuffer -analyzeduration 0 ' '-ar {sample_rate} ' @@ -183,152 +148,11 @@ async def create_audio_output(output: str) -> AudioOutput: ) if output.startswith('file:'): - return FileAudioOutput(output[5:]) + return audio_io.FileAudioOutput(output[5:]) raise ValueError('unsupported audio output') -class AudioOutput(abc.ABC): - """Audio output to which PCM samples can be written.""" - - async def open(self, pcm_format: PcmFormat) -> None: - """Start the output.""" - - @abc.abstractmethod - def write(self, pcm_samples: bytes) -> None: - """Write PCM samples. Must not block.""" - - async def aclose(self) -> None: - """Close the output.""" - - -class ThreadedAudioOutput(AudioOutput): - """Base class for AudioOutput classes that may need to call blocking functions. - - The actual writing is performed in a thread, so as to ensure that calling write() - does not block the caller. - """ - - def __init__(self) -> None: - self._thread_pool = ThreadPoolExecutor(1) - self._pcm_samples: asyncio.Queue[bytes] = asyncio.Queue() - self._write_task = asyncio.create_task(self._write_loop()) - - async def _write_loop(self) -> None: - while True: - pcm_samples = await self._pcm_samples.get() - await asyncio.get_running_loop().run_in_executor( - self._thread_pool, self._write, pcm_samples - ) - - @abc.abstractmethod - def _write(self, pcm_samples: bytes) -> None: - """This method does the actual writing and can block.""" - - def write(self, pcm_samples: bytes) -> None: - self._pcm_samples.put_nowait(pcm_samples) - - def _close(self) -> None: - """This method does the actual closing and can block.""" - - async def aclose(self) -> None: - await asyncio.get_running_loop().run_in_executor(self._thread_pool, self._close) - self._write_task.cancel() - self._thread_pool.shutdown() - - -class SoundDeviceAudioOutput(ThreadedAudioOutput): - def __init__(self, device_name: str) -> None: - super().__init__() - self._device = int(device_name) if device_name else None - self._stream: sounddevice.RawOutputStream | None = None - - async def open(self, pcm_format: PcmFormat) -> None: - import sounddevice - - self._stream = sounddevice.RawOutputStream( - samplerate=pcm_format.sample_rate, - device=self._device, - channels=pcm_format.channels, - dtype='float32', - ) - self._stream.start() - - def _write(self, pcm_samples: bytes) -> None: - if self._stream is None: - return - - try: - self._stream.write(pcm_samples) - except Exception as error: - print(f'Sound device error: {error}') - raise - - def _close(self): - self._stream.stop() - self._stream = None - - -class StreamAudioOutput(ThreadedAudioOutput): - """AudioOutput where PCM samples are written to a stream that may block.""" - - def __init__(self, stream: BinaryIO) -> None: - super().__init__() - self._stream = stream - - def _write(self, pcm_samples: bytes) -> None: - self._stream.write(pcm_samples) - self._stream.flush() - - -class FileAudioOutput(StreamAudioOutput): - """AudioOutput where PCM samples are written to a file.""" - - def __init__(self, filename: str) -> None: - self._file = open(filename, "wb") - super().__init__(self._file) - - async def shutdown(self): - self._file.close() - return await super().shutdown() - - -class SubprocessAudioOutput(AudioOutput): - """AudioOutput where audio samples are written to a subprocess via stdin.""" - - def __init__(self, command: str) -> None: - self._command = command - self._subprocess: asyncio.subprocess.Process | None - - async def open(self, pcm_format: PcmFormat) -> None: - if pcm_format.channels == 1: - channel_layout = 'mono' - elif pcm_format.channels == 2: - channel_layout = 'stereo' - else: - raise ValueError(f'{pcm_format.channels} channels not supported') - - command = self._command.format( - sample_rate=pcm_format.sample_rate, channel_layout=channel_layout - ) - self._subprocess = await asyncio.create_subprocess_shell( - command, - stdin=asyncio.subprocess.PIPE, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - ) - - def write(self, pcm_samples: bytes) -> None: - if self._subprocess is None or self._subprocess.stdin is None: - return - - self._subprocess.stdin.write(pcm_samples) - - async def aclose(self): - if self._subprocess: - self._subprocess.terminate() - - def check_audio_input(input: str) -> bool: if input == 'device' or input.startswith('device:'): try: @@ -378,23 +202,23 @@ def check_audio_input(input: str) -> bool: return True -async def create_audio_input(input: str, input_format: str) -> AudioInput: - pcm_format: PcmFormat | None +async def create_audio_input(input: str, input_format: str) -> audio_io.AudioInput: + pcm_format: audio_io.PcmFormat | None if input_format == 'auto': pcm_format = None else: - pcm_format = PcmFormat.from_str(input_format) + pcm_format = audio_io.PcmFormat.from_str(input_format) if input == 'stdin': if not pcm_format: raise ValueError('input format details required for stdin') - return StreamAudioInput(sys.stdin.buffer, pcm_format) + return audio_io.StreamAudioInput(sys.stdin.buffer, pcm_format) if input == 'device' or input.startswith('device:'): if not pcm_format: raise ValueError('input format details required for device') device_name = '' if input == 'device' else input[7:] - return SoundDeviceAudioInput(device_name, pcm_format) + return audio_io.SoundDeviceAudioInput(device_name, pcm_format) # If there's no file: prefix, check if we can assume it is a file. if pathlib.Path(input).is_file(): @@ -405,177 +229,35 @@ async def create_audio_input(input: str, input_format: str) -> AudioInput: if filename.endswith('.wav'): if input_format != 'auto': raise ValueError(".wav file only supported with 'auto' format") - return WaveAudioInput(filename) + return audio_io.WaveAudioInput(filename) if pcm_format is None: raise ValueError('input format details required for raw PCM files') - return FileAudioInput(filename, pcm_format) + return audio_io.FileAudioInput(filename, pcm_format) raise ValueError('input not supported') -class AudioInput(abc.ABC): - """Audio input that produces PCM samples.""" - - @abc.abstractmethod - async def open(self) -> PcmFormat: - """Open the input.""" - - @abc.abstractmethod - def frames(self, frame_size: int) -> AsyncGenerator[bytes]: - """Generate one frame of PCM samples. Must not block.""" - - async def aclose(self) -> None: - """Close the input.""" - - -class ThreadedAudioInput(AudioInput): - """Base class for AudioInput implementation where reading samples may block.""" - - def __init__(self) -> None: - self._thread_pool = ThreadPoolExecutor(1) - self._pcm_samples: asyncio.Queue[bytes] = asyncio.Queue() - - @abc.abstractmethod - def _read(self, frame_size: int) -> bytes: - pass - - @abc.abstractmethod - def _open(self) -> PcmFormat: - pass - - def _close(self) -> None: - pass - - async def open(self) -> PcmFormat: - return await asyncio.get_running_loop().run_in_executor( - self._thread_pool, self._open +# ----------------------------------------------------------------------------- +# Utils +# ----------------------------------------------------------------------------- +def codec_config_string( + codec_config: bap.CodecSpecificConfiguration, indent: str +) -> str: + lines = [] + if codec_config.sampling_frequency is not None: + lines.append(f'Sampling Frequency: {codec_config.sampling_frequency.hz} hz') + if codec_config.frame_duration is not None: + lines.append(f'Frame Duration: {codec_config.frame_duration.us} µs') + if codec_config.octets_per_codec_frame is not None: + lines.append(f'Frame Size: {codec_config.octets_per_codec_frame} bytes') + if codec_config.codec_frames_per_sdu is not None: + lines.append(f'Frames Per SDU: {codec_config.codec_frames_per_sdu}') + if codec_config.audio_channel_allocation is not None: + lines.append( + f'Audio Location: {codec_config.audio_channel_allocation.name}' ) - - async def frames(self, frame_size: int) -> AsyncGenerator[bytes]: - while pcm_sample := await asyncio.get_running_loop().run_in_executor( - self._thread_pool, self._read, frame_size - ): - yield pcm_sample - - async def aclose(self) -> None: - await asyncio.get_running_loop().run_in_executor(self._thread_pool, self._close) - self._thread_pool.shutdown() - - -class WaveAudioInput(ThreadedAudioInput): - """Audio input that reads PCM samples from a .wav file.""" - - def __init__(self, filename: str) -> None: - super().__init__() - self._filename = filename - self._wav: wave.Wave_read | None = None - self._bytes_read = 0 - - def _open(self) -> PcmFormat: - self._wav = wave.open(self._filename, 'rb') - if self._wav.getsampwidth() != 2: - raise ValueError('sample width not supported') - return PcmFormat( - PcmFormat.Endianness.LITTLE, - PcmFormat.SampleType.INT16, - self._wav.getframerate(), - self._wav.getnchannels(), - ) - - def _read(self, frame_size: int) -> bytes: - if not self._wav: - return b'' - - pcm_samples = self._wav.readframes(frame_size) - if not pcm_samples and self._bytes_read: - # Loop around. - self._wav.rewind() - self._bytes_read = 0 - pcm_samples = self._wav.readframes(frame_size) - - self._bytes_read += len(pcm_samples) - return pcm_samples - - def _close(self) -> None: - if self._wav: - self._wav.close() - - -class StreamAudioInput(ThreadedAudioInput): - """AudioInput where samples are read from a raw PCM stream that may block.""" - - def __init__(self, stream: BinaryIO, pcm_format: PcmFormat) -> None: - super().__init__() - self._stream = stream - self._pcm_format = pcm_format - - def _open(self) -> PcmFormat: - return self._pcm_format - - def _read(self, frame_size: int) -> bytes: - return self._stream.read(frame_size * self._pcm_format.channels * 2) - - -class FileAudioInput(StreamAudioInput): - """AudioInput where PCM samples are read from a raw PCM file.""" - - def __init__(self, filename: str, pcm_format: PcmFormat) -> None: - self._stream = open(filename, "rb") - super().__init__(self._stream, pcm_format) - - def _read(self, frame_size: int) -> bytes: - return self._stream.read(frame_size * self._pcm_format.channels * 2) - - def _close(self) -> None: - self._stream.close() - - -class SoundDeviceAudioInput(ThreadedAudioInput): - def __init__(self, device_name: str, pcm_format: PcmFormat) -> None: - super().__init__() - self._device = int(device_name) if device_name else None - self._pcm_format = pcm_format - self._stream: sounddevice.RawInputStream | None = None - - def _open(self) -> PcmFormat: - import sounddevice - - self._stream = sounddevice.RawInputStream( - samplerate=self._pcm_format.sample_rate, - device=self._device, - channels=self._pcm_format.channels, - dtype='int16', - ) - self._stream.start() - - return PcmFormat( - PcmFormat.Endianness.LITTLE, - PcmFormat.SampleType.INT16, - self._pcm_format.sample_rate, - 2, - ) - - def _read(self, frame_size: int) -> bytes: - if not self._stream: - return b'' - pcm_buffer, overflowed = self._stream.read(frame_size) - if overflowed: - logger.warning("input overflow") - - # Convert the buffer to stereo if needed - if self._pcm_format.channels == 1: - stereo_buffer = bytearray() - for i in range(frame_size): - sample = pcm_buffer[i * 2 : i * 2 + 2] - stereo_buffer += sample + sample - return stereo_buffer - - return bytes(pcm_buffer) - - def _close(self): - self._stream.stop() - self._stream = None + return '\n'.join(indent + line for line in lines) # ----------------------------------------------------------------------------- @@ -670,18 +352,17 @@ class BroadcastScanner(pyee.EventEmitter): if self.public_broadcast_announcement: print( f' {color("Features", "cyan")}: ' - f'{self.public_broadcast_announcement.features}' - ) - print( - f' {color("Metadata", "cyan")}: ' - f'{self.public_broadcast_announcement.metadata}' + f'{self.public_broadcast_announcement.features.name}' ) + print(f' {color("Metadata", "cyan")}:') + print(self.public_broadcast_announcement.metadata.pretty_print(' ')) if self.basic_audio_announcement: print(color(' Audio:', 'cyan')) print( color(' Presentation Delay:', 'magenta'), self.basic_audio_announcement.presentation_delay, + "µs", ) for subgroup in self.basic_audio_announcement.subgroups: print(color(' Subgroup:', 'magenta')) @@ -698,17 +379,22 @@ class BroadcastScanner(pyee.EventEmitter): color(' Vendor Specific Codec ID:', 'green'), subgroup.codec_id.vendor_specific_codec_id, ) + print(color(' Codec Config:', 'yellow')) print( - color(' Codec Config:', 'yellow'), - subgroup.codec_specific_configuration, + codec_config_string( + subgroup.codec_specific_configuration, ' ' + ), ) - print(color(' Metadata: ', 'yellow'), subgroup.metadata) + print(color(' Metadata: ', 'yellow')) + print(subgroup.metadata.pretty_print(' ')) for bis in subgroup.bis: print(color(f' BIS [{bis.index}]:', 'yellow')) + print(color(' Codec Config:', 'green')) print( - color(' Codec Config:', 'green'), - bis.codec_specific_configuration, + codec_config_string( + bis.codec_specific_configuration, ' ' + ), ) if self.biginfo: @@ -1008,7 +694,7 @@ async def run_assist( except core.ProtocolError as error: print( color( - f'!!! Failed to subscribe to Broadcast Receive State characteristic:', + '!!! Failed to subscribe to Broadcast Receive State characteristic', 'red', ), error, @@ -1217,31 +903,52 @@ async def run_receive( sample_rate_hz=sampling_frequency.hz, num_channels=num_bis, ) - sdus = [b''] * num_bis + lc3_queues: list[Deque[bytes]] = [collections.deque() for i in range(num_bis)] + packet_stats = [0, 0] async with contextlib.aclosing( await create_audio_output(output) ) as audio_output: await audio_output.open( - PcmFormat( - PcmFormat.Endianness.LITTLE, - PcmFormat.SampleType.FLOAT32, + audio_io.PcmFormat( + audio_io.PcmFormat.Endianness.LITTLE, + audio_io.PcmFormat.SampleType.FLOAT32, sampling_frequency.hz, num_bis, ) ) + def sink(queue: Deque[bytes], packet: hci.HCI_IsoDataPacket): + # TODO: re-assemble fragments and detect errors + queue.append(packet.iso_sdu_fragment) + + while all(lc3_queues): + # This assumes SDUs contain one LC3 frame each, which may not + # be correct for all cases. TODO: revisit this assumption. + frame = b''.join([lc3_queue.popleft() for lc3_queue in lc3_queues]) + if not frame: + print(color('!!! received empty frame', 'red')) + continue + + packet_stats[0] += len(frame) + packet_stats[1] += 1 + print( + f'\rRECEIVED: {packet_stats[0]} bytes in ' + f'{packet_stats[1]} packets', + end='', + ) + + try: + pcm = decoder.decode(frame).tobytes() + except lc3.BaseError as error: + print(color(f'!!! LC3 decoding error: {error}')) + continue + + audio_output.write(pcm) + for i, bis_link in enumerate(big_sync.bis_links): print(f'Setup ISO for BIS {bis_link.handle}') - - def sink(index: int, packet: hci.HCI_IsoDataPacket): - nonlocal sdus - sdus[index] = packet.iso_sdu_fragment - if all(sdus): - audio_output.write(decoder.decode(b''.join(sdus)).tobytes()) - sdus = [b''] * num_bis - - bis_link.sink = functools.partial(sink, i) + bis_link.sink = functools.partial(sink, lc3_queues[i]) await device.send_command( hci.HCI_LE_Setup_ISO_Data_Path_Command( connection_handle=bis_link.handle, @@ -1259,11 +966,12 @@ async def run_receive( await terminated.wait() -async def run_broadcast( +async def run_transmit( transport: str, broadcast_id: int, broadcast_code: str | None, broadcast_name: str, + bitrate: int, manufacturer_data: tuple[int, bytes] | None, input: str, input_format: str, @@ -1365,34 +1073,6 @@ async def run_broadcast( print('Start Periodic Advertising') await advertising_set.start_periodic() - print('Setup BIG') - big = await device.create_big( - advertising_set, - parameters=bumble.device.BigParameters( - num_bis=2, - sdu_interval=AURACAST_DEFAULT_FRAME_DURATION, - max_sdu=100, - max_transport_latency=65, - rtn=4, - broadcast_code=( - bytes.fromhex(broadcast_code) if broadcast_code else None - ), - ), - ) - print('Setup ISO Data Path') - - data_packet_queue = big.bis_links[0].data_packet_queue - - def on_flow(): - print( - f'\rPACKETS: pending={data_packet_queue.pending}, ' - f'queued={data_packet_queue.queued}, ' - f'completed={data_packet_queue.completed}', - end='', - ) - - data_packet_queue.on('flow', on_flow) - audio_input = await create_audio_input(input, input_format) pcm_format = await audio_input.open() if pcm_format.channels != 2: @@ -1404,25 +1084,62 @@ async def run_broadcast( num_channels=pcm_format.channels, input_sample_rate_hz=pcm_format.sample_rate, ) - frame_size = encoder.get_frame_samples() + lc3_frame_samples = encoder.get_frame_samples() + lc3_frame_size = encoder.get_frame_bytes(bitrate) + + print('Setup BIG') + big = await device.create_big( + advertising_set, + parameters=bumble.device.BigParameters( + num_bis=pcm_format.channels, + sdu_interval=AURACAST_DEFAULT_FRAME_DURATION, + max_sdu=lc3_frame_size, + max_transport_latency=65, + rtn=4, + broadcast_code=( + bytes.fromhex(broadcast_code) if broadcast_code else None + ), + ), + ) + + iso_queues = [ + bumble.device.IsoPacketStream(big.bis_links[0], 2), + bumble.device.IsoPacketStream(big.bis_links[1], 2), + ] + + def on_flow(): + data_packet_queue = iso_queues[0].data_packet_queue + print( + f'\rPACKETS: pending={data_packet_queue.pending}, ' + f'queued={data_packet_queue.queued}, ' + f'completed={data_packet_queue.completed}', + end='', + ) + + iso_queues[0].data_packet_queue.on('flow', on_flow) + async with contextlib.aclosing(audio_input): frame_count = 0 - start_time = time.time() - async for pcm_frame in audio_input.frames(frame_size): - now = time.time() - if ( - target_time := ( - start_time - + frame_count * AURACAST_DEFAULT_FRAME_DURATION / 1_000_000 - ) - ) >= now: - await asyncio.sleep(target_time - now) + # start_time = time.time() + async for pcm_frame in audio_input.frames(lc3_frame_samples): + # now = time.time() + # if ( + # target_time := ( + # start_time + # + frame_count * AURACAST_DEFAULT_FRAME_DURATION / 1_000_000 + # ) + # ) >= now: + # await asyncio.sleep(target_time - now) - lc3_frame = encoder.encode(pcm_frame, num_bytes=200, bit_depth=16) + lc3_frame = encoder.encode( + pcm_frame, num_bytes=2 * lc3_frame_size, bit_depth=16 + ) mid = len(lc3_frame) // 2 - big.bis_links[0].write(lc3_frame[:mid]) - big.bis_links[1].write(lc3_frame[mid:]) + # big.bis_links[0].write(lc3_frame[:mid]) + # big.bis_links[1].write(lc3_frame[mid:]) + await iso_queues[0].write(lc3_frame[:mid]) + await iso_queues[1].write(lc3_frame[mid:]) frame_count += 1 @@ -1569,7 +1286,7 @@ def receive( ) -@auracast.command('broadcast') +@auracast.command('transmit') @click.argument('transport') @click.option( '--input', @@ -1581,7 +1298,7 @@ def receive( "(specify 'device:?' to get a list of available sound input devices), " "'stdin' -> receive audio from stdin as int16 PCM, " "'file: -> read audio from a .wav or raw int16 PCM file. " - "(The file: prefix may be ommitted if the file path does not start with " + "(The file: prefix may be omitted if the file path does not start with " "the substring 'device:' or 'file:' and is not 'stdin')" ), ) @@ -1615,23 +1332,30 @@ def receive( default='Bumble Auracast', help='Broadcast name', ) +@click.option( + '--bitrate', + type=int, + default=AURACAST_DEFAULT_TRANSMIT_BITRATE, + help='Bitrate, per channel, in bps', +) @click.option( '--manufacturer-data', metavar='VENDOR-ID:DATA-HEX', help='Manufacturer data (specify as :)', ) @click.pass_context -def broadcast( +def transmit( ctx, transport, broadcast_id, broadcast_code, manufacturer_data, broadcast_name, + bitrate, input, input_format, ): - """Start a broadcast as a source.""" + """Transmit a broadcast source.""" if manufacturer_data: vendor_id_str, data_hex = manufacturer_data.split(':') vendor_id = int(vendor_id_str) @@ -1645,11 +1369,12 @@ def broadcast( input_format = 'int16le,48000,1' run_async( - run_broadcast( + run_transmit( transport=transport, broadcast_id=broadcast_id, broadcast_code=broadcast_code, broadcast_name=broadcast_name, + bitrate=bitrate, manufacturer_data=manufacturer_data_tuple, input=input, input_format=input_format, diff --git a/apps/device_info.py b/apps/device_info.py index df18c65d..2e57a3f7 100644 --- a/apps/device_info.py +++ b/apps/device_info.py @@ -29,7 +29,9 @@ from bumble.gatt import Service from bumble.profiles.device_information_service import DeviceInformationServiceProxy from bumble.profiles.battery_service import BatteryServiceProxy from bumble.profiles.gap import GenericAccessServiceProxy +from bumble.profiles.pacs import PublishedAudioCapabilitiesServiceProxy from bumble.profiles.tmap import TelephonyAndMediaAudioServiceProxy +from bumble.profiles.vcs import VolumeControlServiceProxy from bumble.transport import open_transport_or_link @@ -126,14 +128,52 @@ async def show_tmas( print(color('### Telephony And Media Audio Service', 'yellow')) if tmas.role: - print( - color(' Role:', 'green'), - await tmas.role.read_value(), - ) + role = await tmas.role.read_value() + print(color(' Role:', 'green'), role) print() +# ----------------------------------------------------------------------------- +async def show_pacs(pacs: PublishedAudioCapabilitiesServiceProxy) -> None: + print(color('### Published Audio Capabilities Service', 'yellow')) + + contexts = await pacs.available_audio_contexts.read_value() + print(color(' Available Audio Contexts:', 'green'), contexts) + + contexts = await pacs.supported_audio_contexts.read_value() + print(color(' Supported Audio Contexts:', 'green'), contexts) + + if pacs.sink_pac: + pac = await pacs.sink_pac.read_value() + print(color(' Sink PAC: ', 'green'), pac) + + if pacs.sink_audio_locations: + audio_locations = await pacs.sink_audio_locations.read_value() + print(color(' Sink Audio Locations: ', 'green'), audio_locations) + + if pacs.source_pac: + pac = await pacs.source_pac.read_value() + print(color(' Source PAC: ', 'green'), pac) + + if pacs.source_audio_locations: + audio_locations = await pacs.source_audio_locations.read_value() + print(color(' Source Audio Locations: ', 'green'), audio_locations) + + print() + + +# ----------------------------------------------------------------------------- +async def show_vcs(vcs: VolumeControlServiceProxy) -> None: + print(color('### Volume Control Service', 'yellow')) + + volume_state = await vcs.volume_state.read_value() + print(color(' Volume State:', 'green'), volume_state) + + volume_flags = await vcs.volume_flags.read_value() + print(color(' Volume Flags:', 'green'), volume_flags) + + # ----------------------------------------------------------------------------- async def show_device_info(peer, done: Optional[asyncio.Future]) -> None: try: @@ -161,6 +201,12 @@ async def show_device_info(peer, done: Optional[asyncio.Future]) -> None: if tmas := peer.create_service_proxy(TelephonyAndMediaAudioServiceProxy): await try_show(show_tmas, tmas) + if pacs := peer.create_service_proxy(PublishedAudioCapabilitiesServiceProxy): + await try_show(show_pacs, pacs) + + if vcs := peer.create_service_proxy(VolumeControlServiceProxy): + await try_show(show_vcs, vcs) + if done is not None: done.set_result(None) except asyncio.CancelledError: diff --git a/bumble/device.py b/bumble/device.py index e58d9424..b7358bb0 100644 --- a/bumble/device.py +++ b/bumble/device.py @@ -17,6 +17,7 @@ # ----------------------------------------------------------------------------- from __future__ import annotations import asyncio +import collections from collections.abc import Iterable, Sequence from contextlib import ( asynccontextmanager, @@ -36,6 +37,7 @@ from typing import ( Any, Callable, ClassVar, + Deque, Dict, Optional, Type, @@ -1433,6 +1435,49 @@ class BisLink(_IsoLink): self.device = self.big.device +# ----------------------------------------------------------------------------- +class IsoPacketStream: + """Async stream that can write SDUs to a CIS or BIS, with a maximum queue size.""" + + iso_link: _IsoLink + data_packet_queue: DataPacketQueue + + def __init__(self, iso_link: _IsoLink, max_queue_size: int) -> None: + if iso_link.data_packet_queue is None: + raise ValueError('link has no data packet queue') + + self.iso_link = iso_link + self.data_packet_queue = iso_link.data_packet_queue + self.data_packet_queue.on('flow', self._on_flow) + self._thresholds: Deque[int] = collections.deque() + self._semaphore = asyncio.Semaphore(max_queue_size) + + def _on_flow(self) -> None: + # Release the semaphore once for each completed packet. + while ( + self._thresholds and self.data_packet_queue.completed >= self._thresholds[0] + ): + self._thresholds.popleft() + self._semaphore.release() + + async def write(self, sdu: bytes) -> None: + """ + Write an SDU to the queue. + + This method blocks until there are fewer than max_queue_size packets queued + but not yet completed. + """ + + # Wait until there's space in the queue. + await self._semaphore.acquire() + + # Queue the packet. + self.iso_link.write(sdu) + + # Remember the position of the packet so we can know when it is completed. + self._thresholds.append(self.data_packet_queue.queued) + + # ----------------------------------------------------------------------------- class Connection(CompositeEventEmitter): device: Device diff --git a/bumble/gatt.py b/bumble/gatt.py index a800657d..8192f87e 100644 --- a/bumble/gatt.py +++ b/bumble/gatt.py @@ -323,8 +323,6 @@ GATT_SERVER_SUPPORTED_FEATURES_CHARACTERISTIC = UUID.from_16_bi # ----------------------------------------------------------------------------- # Utils # ----------------------------------------------------------------------------- - - def show_services(services: Iterable[Service]) -> None: for service in services: print(color(str(service), 'cyan')) diff --git a/bumble/gatt_client.py b/bumble/gatt_client.py index 1362b1ed..73b8a631 100644 --- a/bumble/gatt_client.py +++ b/bumble/gatt_client.py @@ -78,6 +78,7 @@ from .gatt import ( GATT_INCLUDE_ATTRIBUTE_TYPE, Characteristic, ClientCharacteristicConfigurationBits, + InvalidServiceError, TemplateService, ) @@ -162,12 +163,23 @@ class ServiceProxy(AttributeProxy): self.uuid = uuid self.characteristics = [] - async def discover_characteristics(self, uuids=()): + async def discover_characteristics(self, uuids=()) -> list[CharacteristicProxy]: return await self.client.discover_characteristics(uuids, self) - def get_characteristics_by_uuid(self, uuid): + def get_characteristics_by_uuid(self, uuid: UUID) -> list[CharacteristicProxy]: + """Get all the characteristics with a specified UUID.""" return self.client.get_characteristics_by_uuid(uuid, self) + def get_required_characteristic_by_uuid(self, uuid: UUID) -> CharacteristicProxy: + """ + Get the first characteristic with a specified UUID. + + If no characteristic with that UUID is found, an InvalidServiceError is raised. + """ + if not (characteristics := self.get_characteristics_by_uuid(uuid)): + raise InvalidServiceError(f'{uuid} characteristic not found') + return characteristics[0] + def __str__(self) -> str: return f'Service(handle=0x{self.handle:04X}, uuid={self.uuid})' diff --git a/bumble/profiles/aics.py b/bumble/profiles/aics.py index 2c16336b..6e0e56e7 100644 --- a/bumble/profiles/aics.py +++ b/bumble/profiles/aics.py @@ -451,54 +451,35 @@ class AICSServiceProxy(ProfileServiceProxy): def __init__(self, service_proxy: ServiceProxy) -> None: self.service_proxy = service_proxy - if not ( - characteristics := service_proxy.get_characteristics_by_uuid( - GATT_AUDIO_INPUT_STATE_CHARACTERISTIC - ) - ): - raise gatt.InvalidServiceError("Audio Input State Characteristic not found") self.audio_input_state = SerializableCharacteristicAdapter( - characteristics[0], AudioInputState + service_proxy.get_required_characteristic_by_uuid( + GATT_AUDIO_INPUT_STATE_CHARACTERISTIC + ), + AudioInputState, ) - if not ( - characteristics := service_proxy.get_characteristics_by_uuid( - GATT_GAIN_SETTINGS_ATTRIBUTE_CHARACTERISTIC - ) - ): - raise gatt.InvalidServiceError( - "Gain Settings Attribute Characteristic not found" - ) self.gain_settings_properties = SerializableCharacteristicAdapter( - characteristics[0], GainSettingsProperties + service_proxy.get_required_characteristic_by_uuid( + GATT_GAIN_SETTINGS_ATTRIBUTE_CHARACTERISTIC + ), + GainSettingsProperties, ) - if not ( - characteristics := service_proxy.get_characteristics_by_uuid( + self.audio_input_status = PackedCharacteristicAdapter( + service_proxy.get_required_characteristic_by_uuid( GATT_AUDIO_INPUT_STATUS_CHARACTERISTIC - ) - ): - raise gatt.InvalidServiceError( - "Audio Input Status Characteristic not found" - ) - self.audio_input_status = PackedCharacteristicAdapter(characteristics[0], 'B') + ), + 'B', + ) - if not ( - characteristics := service_proxy.get_characteristics_by_uuid( + self.audio_input_control_point = ( + service_proxy.get_required_characteristic_by_uuid( GATT_AUDIO_INPUT_CONTROL_POINT_CHARACTERISTIC ) - ): - raise gatt.InvalidServiceError( - "Audio Input Control Point Characteristic not found" - ) - self.audio_input_control_point = characteristics[0] + ) - if not ( - characteristics := service_proxy.get_characteristics_by_uuid( + self.audio_input_description = UTF8CharacteristicAdapter( + service_proxy.get_required_characteristic_by_uuid( GATT_AUDIO_INPUT_DESCRIPTION_CHARACTERISTIC ) - ): - raise gatt.InvalidServiceError( - "Audio Input Description Characteristic not found" - ) - self.audio_input_description = UTF8CharacteristicAdapter(characteristics[0]) + ) diff --git a/bumble/profiles/asha.py b/bumble/profiles/asha.py index b2aa4418..67838a97 100644 --- a/bumble/profiles/asha.py +++ b/bumble/profiles/asha.py @@ -288,8 +288,8 @@ class AshaServiceProxy(gatt_client.ProfileServiceProxy): 'psm_characteristic', ), ): - if not ( - characteristics := self.service_proxy.get_characteristics_by_uuid(uuid) - ): - raise gatt.InvalidServiceError(f"Missing {uuid} Characteristic") - setattr(self, attribute_name, characteristics[0]) + setattr( + self, + attribute_name, + self.service_proxy.get_required_characteristic_by_uuid(uuid), + ) diff --git a/bumble/profiles/bass.py b/bumble/profiles/bass.py index 9ded4ef9..18370063 100644 --- a/bumble/profiles/bass.py +++ b/bumble/profiles/bass.py @@ -354,34 +354,25 @@ class BroadcastAudioScanServiceProxy(gatt_client.ProfileServiceProxy): SERVICE_CLASS = BroadcastAudioScanService broadcast_audio_scan_control_point: gatt_client.CharacteristicProxy - broadcast_receive_states: List[gatt.SerializableCharacteristicAdapter] + broadcast_receive_states: List[gatt.DelegatedCharacteristicAdapter] def __init__(self, service_proxy: gatt_client.ServiceProxy): self.service_proxy = service_proxy - if not ( - characteristics := service_proxy.get_characteristics_by_uuid( + self.broadcast_audio_scan_control_point = ( + service_proxy.get_required_characteristic_by_uuid( gatt.GATT_BROADCAST_AUDIO_SCAN_CONTROL_POINT_CHARACTERISTIC ) - ): - raise gatt.InvalidServiceError( - "Broadcast Audio Scan Control Point characteristic not found" - ) - self.broadcast_audio_scan_control_point = characteristics[0] + ) - if not ( - characteristics := service_proxy.get_characteristics_by_uuid( + self.broadcast_receive_states = [ + gatt.DelegatedCharacteristicAdapter( + characteristic, + decode=lambda x: BroadcastReceiveState.from_bytes(x) if x else None, + ) + for characteristic in service_proxy.get_characteristics_by_uuid( gatt.GATT_BROADCAST_RECEIVE_STATE_CHARACTERISTIC ) - ): - raise gatt.InvalidServiceError( - "Broadcast Receive State characteristic not found" - ) - self.broadcast_receive_states = [ - gatt.SerializableCharacteristicAdapter( - characteristic, BroadcastReceiveState - ) - for characteristic in characteristics ] async def send_control_point_operation( diff --git a/bumble/profiles/gmap.py b/bumble/profiles/gmap.py index 929ad522..e81fd8d5 100644 --- a/bumble/profiles/gmap.py +++ b/bumble/profiles/gmap.py @@ -30,7 +30,6 @@ from bumble.gatt import ( GATT_UGT_FEATURES_CHARACTERISTIC, GATT_BGS_FEATURES_CHARACTERISTIC, GATT_BGR_FEATURES_CHARACTERISTIC, - InvalidServiceError, ) from bumble.gatt_client import ProfileServiceProxy, ServiceProxy from enum import IntFlag @@ -154,14 +153,10 @@ class GamingAudioServiceProxy(ProfileServiceProxy): def __init__(self, service_proxy: ServiceProxy) -> None: self.service_proxy = service_proxy - if not ( - characteristics := service_proxy.get_characteristics_by_uuid( - GATT_GMAP_ROLE_CHARACTERISTIC - ) - ): - raise InvalidServiceError("GMAP Role Characteristic not found") self.gmap_role = DelegatedCharacteristicAdapter( - characteristic=characteristics[0], + service_proxy.get_required_characteristic_by_uuid( + GATT_GMAP_ROLE_CHARACTERISTIC + ), decode=lambda value: GmapRole(value[0]), ) diff --git a/bumble/profiles/le_audio.py b/bumble/profiles/le_audio.py index 9b8ba3d8..19afee4c 100644 --- a/bumble/profiles/le_audio.py +++ b/bumble/profiles/le_audio.py @@ -17,6 +17,7 @@ # ----------------------------------------------------------------------------- from __future__ import annotations import dataclasses +import enum import struct from typing import Any, List, Type from typing_extensions import Self @@ -41,9 +42,10 @@ class AssistedListeningStream(utils.OpenIntEnum): class Metadata: '''Bluetooth Assigned Numbers, Section 6.12.6 - Metadata LTV structures. - As Metadata fields may extend, and Spec doesn't forbid duplication, we don't - automatically parse the Metadata data into specific classes. Callers may decode - the data by themselves, or use the Entry.decode method. + As Metadata fields may extend, and the spec may not guarantee the uniqueness of + tags, we don't automatically parse the Metadata data into specific classes. + Users of this class may decode the data by themselves, or use the Entry.decode + method. ''' class Tag(utils.OpenIntEnum): @@ -114,6 +116,29 @@ class Metadata: entries: List[Entry] = dataclasses.field(default_factory=list) + def pretty_print(self, indent: str) -> str: + """Convenience method to generate a string with one key-value pair per line.""" + + max_key_length = 0 + keys = [] + values = [] + for entry in self.entries: + key = entry.tag.name + max_key_length = max(max_key_length, len(key)) + keys.append(key) + decoded = entry.decode() + if isinstance(decoded, enum.Enum): + values.append(decoded.name) + elif isinstance(decoded, bytes): + values.append(decoded.hex()) + else: + values.append(str(decoded)) + + return '\n'.join( + f'{indent}{key}: {" " * (max_key_length-len(key))}{value}' + for key, value in zip(keys, values) + ) + @classmethod def from_bytes(cls: Type[Self], data: bytes) -> Self: entries = [] diff --git a/bumble/profiles/pacs.py b/bumble/profiles/pacs.py index adab088a..983bfbfb 100644 --- a/bumble/profiles/pacs.py +++ b/bumble/profiles/pacs.py @@ -72,6 +72,19 @@ class PacRecord: metadata=metadata, ) + @classmethod + def list_from_bytes(cls, data: bytes) -> list[PacRecord]: + """Parse a serialized list of records preceded by a one byte list length.""" + record_count = data[0] + records = [] + offset = 1 + for _ in range(record_count): + record = PacRecord.from_bytes(data[offset:]) + offset += len(bytes(record)) + records.append(record) + + return records + def __bytes__(self) -> bytes: capabilities_bytes = bytes(self.codec_specific_capabilities) metadata_bytes = bytes(self.metadata) @@ -172,39 +185,58 @@ class PublishedAudioCapabilitiesService(gatt.TemplateService): class PublishedAudioCapabilitiesServiceProxy(gatt_client.ProfileServiceProxy): SERVICE_CLASS = PublishedAudioCapabilitiesService - sink_pac: Optional[gatt_client.CharacteristicProxy] = None - sink_audio_locations: Optional[gatt_client.CharacteristicProxy] = None - source_pac: Optional[gatt_client.CharacteristicProxy] = None - source_audio_locations: Optional[gatt_client.CharacteristicProxy] = None - available_audio_contexts: gatt_client.CharacteristicProxy - supported_audio_contexts: gatt_client.CharacteristicProxy + sink_pac: Optional[gatt.DelegatedCharacteristicAdapter] = None + sink_audio_locations: Optional[gatt.DelegatedCharacteristicAdapter] = None + source_pac: Optional[gatt.DelegatedCharacteristicAdapter] = None + source_audio_locations: Optional[gatt.DelegatedCharacteristicAdapter] = None + available_audio_contexts: gatt.DelegatedCharacteristicAdapter + supported_audio_contexts: gatt.DelegatedCharacteristicAdapter def __init__(self, service_proxy: gatt_client.ServiceProxy): self.service_proxy = service_proxy - self.available_audio_contexts = service_proxy.get_characteristics_by_uuid( - gatt.GATT_AVAILABLE_AUDIO_CONTEXTS_CHARACTERISTIC - )[0] - self.supported_audio_contexts = service_proxy.get_characteristics_by_uuid( - gatt.GATT_SUPPORTED_AUDIO_CONTEXTS_CHARACTERISTIC - )[0] + self.available_audio_contexts = gatt.DelegatedCharacteristicAdapter( + service_proxy.get_required_characteristic_by_uuid( + gatt.GATT_AVAILABLE_AUDIO_CONTEXTS_CHARACTERISTIC + ), + decode=lambda x: tuple(map(ContextType, struct.unpack(' VolumeState: + return cls(data[0], data[1], data[2]) + + def __bytes__(self) -> bytes: + return bytes([self.volume_setting, self.mute, self.change_counter]) + + # ----------------------------------------------------------------------------- # Server # ----------------------------------------------------------------------------- @@ -126,16 +142,8 @@ class VolumeControlService(gatt.TemplateService): included_services=list(included_services), ) - @property - def volume_state_bytes(self) -> bytes: - return bytes([self.volume_setting, self.muted, self.change_counter]) - - @volume_state_bytes.setter - def volume_state_bytes(self, new_value: bytes) -> None: - self.volume_setting, self.muted, self.change_counter = new_value - def _on_read_volume_state(self, _connection: Optional[device.Connection]) -> bytes: - return self.volume_state_bytes + return bytes(VolumeState(self.volume_setting, self.muted, self.change_counter)) def _on_write_volume_control_point( self, connection: Optional[device.Connection], value: bytes @@ -153,14 +161,9 @@ class VolumeControlService(gatt.TemplateService): self.change_counter = (self.change_counter + 1) % 256 connection.abort_on( 'disconnection', - connection.device.notify_subscribers( - attribute=self.volume_state, - value=self.volume_state_bytes, - ), - ) - self.emit( - 'volume_state', self.volume_setting, self.muted, self.change_counter + connection.device.notify_subscribers(attribute=self.volume_state), ) + self.emit('volume_state_change') def _on_relative_volume_down(self) -> bool: old_volume = self.volume_setting @@ -207,24 +210,26 @@ class VolumeControlServiceProxy(gatt_client.ProfileServiceProxy): SERVICE_CLASS = VolumeControlService volume_control_point: gatt_client.CharacteristicProxy + volume_state: gatt.SerializableCharacteristicAdapter + volume_flags: gatt.DelegatedCharacteristicAdapter def __init__(self, service_proxy: gatt_client.ServiceProxy) -> None: self.service_proxy = service_proxy - self.volume_state = gatt.PackedCharacteristicAdapter( - service_proxy.get_characteristics_by_uuid( + self.volume_state = gatt.SerializableCharacteristicAdapter( + service_proxy.get_required_characteristic_by_uuid( gatt.GATT_VOLUME_STATE_CHARACTERISTIC - )[0], - 'BBB', + ), + VolumeState, ) - self.volume_control_point = service_proxy.get_characteristics_by_uuid( + self.volume_control_point = service_proxy.get_required_characteristic_by_uuid( gatt.GATT_VOLUME_CONTROL_POINT_CHARACTERISTIC - )[0] - - self.volume_flags = gatt.PackedCharacteristicAdapter( - service_proxy.get_characteristics_by_uuid( - gatt.GATT_VOLUME_FLAGS_CHARACTERISTIC - )[0], - 'B', + ) + + self.volume_flags = gatt.DelegatedCharacteristicAdapter( + service_proxy.get_required_characteristic_by_uuid( + gatt.GATT_VOLUME_FLAGS_CHARACTERISTIC + ), + decode=lambda data: VolumeFlags(data[0]), ) diff --git a/bumble/profiles/vocs.py b/bumble/profiles/vocs.py index a3dd929e..70764fba 100644 --- a/bumble/profiles/vocs.py +++ b/bumble/profiles/vocs.py @@ -27,8 +27,8 @@ from bumble.gatt import ( DelegatedCharacteristicAdapter, TemplateService, CharacteristicValue, + SerializableCharacteristicAdapter, UTF8CharacteristicAdapter, - InvalidServiceError, GATT_VOLUME_OFFSET_CONTROL_SERVICE, GATT_VOLUME_OFFSET_STATE_CHARACTERISTIC, GATT_AUDIO_LOCATION_CHARACTERISTIC, @@ -287,44 +287,28 @@ class VolumeOffsetControlServiceProxy(ProfileServiceProxy): def __init__(self, service_proxy: ServiceProxy) -> None: self.service_proxy = service_proxy - if not ( - characteristics := service_proxy.get_characteristics_by_uuid( - GATT_VOLUME_OFFSET_STATE_CHARACTERISTIC - ) - ): - raise InvalidServiceError("Volume Offset State characteristic not found") self.volume_offset_state = DelegatedCharacteristicAdapter( - characteristics[0], decode=VolumeOffsetState.from_bytes + service_proxy.get_required_characteristic_by_uuid( + GATT_VOLUME_OFFSET_STATE_CHARACTERISTIC + ), + decode=VolumeOffsetState.from_bytes, ) - if not ( - characteristics := service_proxy.get_characteristics_by_uuid( + self.audio_location = SerializableCharacteristicAdapter( + service_proxy.get_required_characteristic_by_uuid( GATT_AUDIO_LOCATION_CHARACTERISTIC - ) - ): - raise InvalidServiceError("Audio Location characteristic not found") - self.audio_location = DelegatedCharacteristicAdapter( - characteristics[0], - encode=lambda value: bytes(value), - decode=VocsAudioLocation.from_bytes, + ), + VocsAudioLocation, ) - if not ( - characteristics := service_proxy.get_characteristics_by_uuid( + self.volume_offset_control_point = ( + service_proxy.get_required_characteristic_by_uuid( GATT_VOLUME_OFFSET_CONTROL_POINT_CHARACTERISTIC ) - ): - raise InvalidServiceError( - "Volume Offset Control Point characteristic not found" - ) - self.volume_offset_control_point = characteristics[0] + ) - if not ( - characteristics := service_proxy.get_characteristics_by_uuid( + self.audio_output_description = UTF8CharacteristicAdapter( + service_proxy.get_required_characteristic_by_uuid( GATT_AUDIO_OUTPUT_DESCRIPTION_CHARACTERISTIC ) - ): - raise InvalidServiceError( - "Audio Output Description characteristic not found" - ) - self.audio_output_description = UTF8CharacteristicAdapter(characteristics[0]) + ) diff --git a/docs/mkdocs/mkdocs.yml b/docs/mkdocs/mkdocs.yml index 6590d124..f8e92b4a 100644 --- a/docs/mkdocs/mkdocs.yml +++ b/docs/mkdocs/mkdocs.yml @@ -39,12 +39,14 @@ nav: - Drivers: - drivers/index.md - Realtek: drivers/realtek.md + - Intel: drivers/intel.md - API: - Guide: api/guide.md - Examples: api/examples.md - Reference: api/reference.md - Apps & Tools: - apps_and_tools/index.md + - Auracast: apps_and_tools/auracast.md - Console: apps_and_tools/console.md - Bench: apps_and_tools/bench.md - Speaker: apps_and_tools/speaker.md @@ -108,8 +110,8 @@ markdown_extensions: - pymdownx.details - pymdownx.superfences - pymdownx.emoji: - emoji_index: !!python/name:materialx.emoji.twemoji - emoji_generator: !!python/name:materialx.emoji.to_svg + emoji_index: !!python/name:material.extensions.emoji.twemoji + emoji_generator: !!python/name:material.extensions.emoji.to_svg - pymdownx.tabbed: alternate_style: true - codehilite: diff --git a/docs/mkdocs/src/getting_started.md b/docs/mkdocs/src/getting_started.md index 476ecc04..9570849f 100644 --- a/docs/mkdocs/src/getting_started.md +++ b/docs/mkdocs/src/getting_started.md @@ -9,9 +9,9 @@ for your platform. Throughout the documentation, when shell commands are shown, it is assumed that you can invoke Python as ``` -$ python +$ python3 ``` -If invoking python is different on your platform (it may be `python3` for example, or just `py` or `py.exe`), +If invoking python is different on your platform (it may be `python` for example, or just `py` or `py.exe`), adjust accordingly. You may be simply using Bumble as a module for your own application or as a dependency to your own @@ -30,12 +30,18 @@ manager, or from source. python environment, or in a virtual environment, such as a `venv`, `pyenv` or `conda` environment. See the [Python Environments page](development/python_environments.md) page for details. +### Install from PyPI + +``` +$ python3 -m pip install bumble +``` + ### Install From Source Install with `pip`. Run in a command shell in the directory where you downloaded the source distribution ``` -$ python -m pip install -e . +$ python3 -m pip install -e . ``` ### Install from GitHub @@ -44,21 +50,21 @@ You can install directly from GitHub without first downloading the repo. Install the latest commit from the main branch with `pip`: ``` -$ python -m pip install git+https://github.com/google/bumble.git +$ python3 -m pip install git+https://github.com/google/bumble.git ``` You can specify a specific tag. Install tag `v0.0.1` with `pip`: ``` -$ python -m pip install git+https://github.com/google/bumble.git@v0.0.1 +$ python3 -m pip install git+https://github.com/google/bumble.git@v0.0.1 ``` You can also specify a specific commit. Install commit `27c0551` with `pip`: ``` -$ python -m pip install git+https://github.com/google/bumble.git@27c0551 +$ python3 -m pip install git+https://github.com/google/bumble.git@27c0551 ``` # Working On The Bumble Code @@ -78,21 +84,21 @@ directory of the project. ```bash $ export PYTHONPATH=. -$ python apps/console.py serial:/dev/tty.usbmodem0006839912171 +$ python3 apps/console.py serial:/dev/tty.usbmodem0006839912171 ``` or running an example, with the working directory set to the `examples` subdirectory ```bash $ cd examples $ export PYTHONPATH=.. -$ python run_scanner.py usb:0 +$ python3 run_scanner.py usb:0 ``` Or course, `export PYTHONPATH` only needs to be invoked once, not before each app/script execution. Setting `PYTHONPATH` locally with each command would look something like: ``` -$ PYTHONPATH=. python examples/run_advertiser.py examples/device1.json serial:/dev/tty.usbmodem0006839912171 +$ PYTHONPATH=. python3 examples/run_advertiser.py examples/device1.json serial:/dev/tty.usbmodem0006839912171 ``` # Where To Go Next diff --git a/docs/mkdocs/src/platforms/android.md b/docs/mkdocs/src/platforms/android.md index 4b08d883..fda33565 100644 --- a/docs/mkdocs/src/platforms/android.md +++ b/docs/mkdocs/src/platforms/android.md @@ -35,11 +35,11 @@ the command line. visit [this Android Studio user guide page](https://developer.android.com/studio/run/emulator-commandline) The `-packet-streamer-endpoint ` command line option may be used to enable -Bluetooth emulation and tell the emulator which virtual controller to connect to. +Bluetooth emulation and tell the emulator which virtual controller to connect to. ## Connecting to Netsim -If the emulator doesn't have Bluetooth emulation enabled by default, use the +If the emulator doesn't have Bluetooth emulation enabled by default, use the `-packet-streamer-endpoint default` option to tell it to connect to Netsim. If Netsim is not running, the emulator will start it automatically. @@ -60,17 +60,17 @@ the Bumble `android-netsim` transport in `host` mode (the default). !!! example "Run the example GATT server connected to the emulator via Netsim" ``` shell - $ python run_gatt_server.py device1.json android-netsim + $ python3 run_gatt_server.py device1.json android-netsim ``` By default, the Bumble `android-netsim` transport will try to automatically discover the port number on which the netsim process is exposing its gRPC server interface. If -that discovery process fails, or if you want to specify the interface manually, you +that discovery process fails, or if you want to specify the interface manually, you can pass a `hostname` and `port` as parameters to the transport, as: `android-netsim::`. !!! example "Run the example GATT server connected to the emulator via Netsim on a localhost, port 8877" ``` shell - $ python run_gatt_server.py device1.json android-netsim:localhost:8877 + $ python3 run_gatt_server.py device1.json android-netsim:localhost:8877 ``` ### Multiple Instances @@ -84,7 +84,7 @@ For example: `android-netsim:localhost:8877,name=bumble1` This is an advanced use case, which may not be officially supported, but should work in recent versions of the emulator. -The first step is to run the Bumble HCI bridge, specifying netsim as the "host" end of the +The first step is to run the Bumble HCI bridge, specifying netsim as the "host" end of the bridge, and another controller (typically a USB Bluetooth dongle, but any other supported transport can work as well) as the "controller" end of the bridge. diff --git a/examples/run_vcp_renderer.py b/examples/run_vcp_renderer.py index ba9c8404..1002bc69 100644 --- a/examples/run_vcp_renderer.py +++ b/examples/run_vcp_renderer.py @@ -42,7 +42,7 @@ from bumble.profiles.bap import ( from bumble.profiles.pacs import PacRecord, PublishedAudioCapabilitiesService from bumble.profiles.cap import CommonAudioServiceService from bumble.profiles.csip import CoordinatedSetIdentificationService, SirkType -from bumble.profiles.vcp import VolumeControlService +from bumble.profiles.vcs import VolumeControlService from bumble.transport import open_transport_or_link @@ -117,13 +117,17 @@ async def main() -> None: ws: Optional[websockets.WebSocketServerProtocol] = None - def on_volume_state(volume_setting: int, muted: int, change_counter: int): + def on_volume_state_change(): if ws: asyncio.create_task( - ws.send(dumps_volume_state(volume_setting, muted, change_counter)) + ws.send( + dumps_volume_state( + vcs.volume_setting, vcs.muted, vcs.change_counter + ) + ) ) - vcs.on('volume_state', on_volume_state) + vcs.on('volume_state_change', on_volume_state_change) advertising_data = ( bytes( diff --git a/pyproject.toml b/pyproject.toml index 504c2ddc..5d916168 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -61,16 +61,17 @@ avatar = [ ] pandora = ["bt-test-interfaces >= 0.0.6"] documentation = [ - "mkdocs >= 1.4.0", - "mkdocs-material >= 8.5.6", - "mkdocstrings[python] >= 0.19.0", + "mkdocs >= 1.6.0", + "mkdocs-material >= 9.6", + "mkdocstrings[python] >= 0.27.0", ] auracast = [ - "lc3 @ git+https://github.com/google/liblc3.git", + "lc3py ; python_version>='3.10' and platform_system=='Linux' and platform_machine=='x86_64'", "sounddevice >= 0.5.1", ] [project.scripts] +bumble-auracast = "bumble.apps.auracast:main" bumble-ble-rpa-tool = "bumble.apps.ble_rpa_tool:main" bumble-console = "bumble.apps.console:main" bumble-controller-info = "bumble.apps.controller_info:main" diff --git a/tests/aics_test.py b/tests/aics_test.py index 44826a9c..b044c39c 100644 --- a/tests/aics_test.py +++ b/tests/aics_test.py @@ -33,7 +33,7 @@ from bumble.profiles.aics import ( AudioInputControlPointOpCode, ErrorCode, ) -from bumble.profiles.vcp import VolumeControlService, VolumeControlServiceProxy +from bumble.profiles.vcs import VolumeControlService, VolumeControlServiceProxy from .test_utils import TwoDevices diff --git a/tests/vcp_test.py b/tests/vcp_test.py index 5853ed96..4876cb62 100644 --- a/tests/vcp_test.py +++ b/tests/vcp_test.py @@ -20,7 +20,7 @@ import pytest_asyncio import logging from bumble import device -from bumble.profiles import vcp +from bumble.profiles import vcs from .test_utils import TwoDevices # ----------------------------------------------------------------------------- @@ -34,7 +34,7 @@ logger = logging.getLogger(__name__) async def vcp_client(): devices = TwoDevices() devices[0].add_service( - vcp.VolumeControlService(volume_setting=32, muted=1, volume_flags=1) + vcs.VolumeControlService(volume_setting=32, muted=1, volume_flags=1) ) await devices.setup_connection() @@ -48,76 +48,76 @@ async def vcp_client(): peer = device.Peer(devices.connections[1]) vcp_client = await peer.discover_service_and_create_proxy( - vcp.VolumeControlServiceProxy + vcs.VolumeControlServiceProxy ) yield vcp_client # ----------------------------------------------------------------------------- @pytest.mark.asyncio -async def test_init_service(vcp_client: vcp.VolumeControlServiceProxy): +async def test_init_service(vcp_client: vcs.VolumeControlServiceProxy): assert (await vcp_client.volume_flags.read_value()) == 1 - assert (await vcp_client.volume_state.read_value()) == (32, 1, 0) + assert (await vcp_client.volume_state.read_value()) == vcs.VolumeState(32, 1, 0) # ----------------------------------------------------------------------------- @pytest.mark.asyncio -async def test_relative_volume_down(vcp_client: vcp.VolumeControlServiceProxy): +async def test_relative_volume_down(vcp_client: vcs.VolumeControlServiceProxy): await vcp_client.volume_control_point.write_value( - bytes([vcp.VolumeControlPointOpcode.RELATIVE_VOLUME_DOWN, 0]) + bytes([vcs.VolumeControlPointOpcode.RELATIVE_VOLUME_DOWN, 0]) ) - assert (await vcp_client.volume_state.read_value()) == (16, 1, 1) + assert (await vcp_client.volume_state.read_value()) == vcs.VolumeState(16, 1, 1) # ----------------------------------------------------------------------------- @pytest.mark.asyncio -async def test_relative_volume_up(vcp_client: vcp.VolumeControlServiceProxy): +async def test_relative_volume_up(vcp_client: vcs.VolumeControlServiceProxy): await vcp_client.volume_control_point.write_value( - bytes([vcp.VolumeControlPointOpcode.RELATIVE_VOLUME_UP, 0]) + bytes([vcs.VolumeControlPointOpcode.RELATIVE_VOLUME_UP, 0]) ) - assert (await vcp_client.volume_state.read_value()) == (48, 1, 1) + assert (await vcp_client.volume_state.read_value()) == vcs.VolumeState(48, 1, 1) # ----------------------------------------------------------------------------- @pytest.mark.asyncio -async def test_unmute_relative_volume_down(vcp_client: vcp.VolumeControlServiceProxy): +async def test_unmute_relative_volume_down(vcp_client: vcs.VolumeControlServiceProxy): await vcp_client.volume_control_point.write_value( - bytes([vcp.VolumeControlPointOpcode.UNMUTE_RELATIVE_VOLUME_DOWN, 0]) + bytes([vcs.VolumeControlPointOpcode.UNMUTE_RELATIVE_VOLUME_DOWN, 0]) ) - assert (await vcp_client.volume_state.read_value()) == (16, 0, 1) + assert (await vcp_client.volume_state.read_value()) == vcs.VolumeState(16, 0, 1) # ----------------------------------------------------------------------------- @pytest.mark.asyncio -async def test_unmute_relative_volume_up(vcp_client: vcp.VolumeControlServiceProxy): +async def test_unmute_relative_volume_up(vcp_client: vcs.VolumeControlServiceProxy): await vcp_client.volume_control_point.write_value( - bytes([vcp.VolumeControlPointOpcode.UNMUTE_RELATIVE_VOLUME_UP, 0]) + bytes([vcs.VolumeControlPointOpcode.UNMUTE_RELATIVE_VOLUME_UP, 0]) ) - assert (await vcp_client.volume_state.read_value()) == (48, 0, 1) + assert (await vcp_client.volume_state.read_value()) == vcs.VolumeState(48, 0, 1) # ----------------------------------------------------------------------------- @pytest.mark.asyncio -async def test_set_absolute_volume(vcp_client: vcp.VolumeControlServiceProxy): +async def test_set_absolute_volume(vcp_client: vcs.VolumeControlServiceProxy): await vcp_client.volume_control_point.write_value( - bytes([vcp.VolumeControlPointOpcode.SET_ABSOLUTE_VOLUME, 0, 255]) + bytes([vcs.VolumeControlPointOpcode.SET_ABSOLUTE_VOLUME, 0, 255]) ) - assert (await vcp_client.volume_state.read_value()) == (255, 1, 1) + assert (await vcp_client.volume_state.read_value()) == vcs.VolumeState(255, 1, 1) # ----------------------------------------------------------------------------- @pytest.mark.asyncio -async def test_mute(vcp_client: vcp.VolumeControlServiceProxy): +async def test_mute(vcp_client: vcs.VolumeControlServiceProxy): await vcp_client.volume_control_point.write_value( - bytes([vcp.VolumeControlPointOpcode.MUTE, 0]) + bytes([vcs.VolumeControlPointOpcode.MUTE, 0]) ) - assert (await vcp_client.volume_state.read_value()) == (32, 1, 0) + assert (await vcp_client.volume_state.read_value()) == vcs.VolumeState(32, 1, 0) # ----------------------------------------------------------------------------- @pytest.mark.asyncio -async def test_unmute(vcp_client: vcp.VolumeControlServiceProxy): +async def test_unmute(vcp_client: vcs.VolumeControlServiceProxy): await vcp_client.volume_control_point.write_value( - bytes([vcp.VolumeControlPointOpcode.UNMUTE, 0]) + bytes([vcs.VolumeControlPointOpcode.UNMUTE, 0]) ) - assert (await vcp_client.volume_state.read_value()) == (32, 0, 1) + assert (await vcp_client.volume_state.read_value()) == vcs.VolumeState(32, 0, 1) diff --git a/tests/vocs_test.py b/tests/vocs_test.py index f713c176..683654a9 100644 --- a/tests/vocs_test.py +++ b/tests/vocs_test.py @@ -34,7 +34,7 @@ from bumble.profiles.vocs import ( VolumeOffsetState, VocsAudioLocation, ) -from bumble.profiles.vcp import VolumeControlService, VolumeControlServiceProxy +from bumble.profiles.vcs import VolumeControlService, VolumeControlServiceProxy from bumble.profiles.bap import AudioLocation from .test_utils import TwoDevices From 9756572c93593fa066c792aa99c307d8c25e3354 Mon Sep 17 00:00:00 2001 From: Gilles Boccon-Gibod Date: Tue, 4 Feb 2025 17:58:54 -0500 Subject: [PATCH 03/13] add audio module --- bumble/audio/__init__.py | 17 ++ bumble/audio/io.py | 534 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 551 insertions(+) create mode 100644 bumble/audio/__init__.py create mode 100644 bumble/audio/io.py diff --git a/bumble/audio/__init__.py b/bumble/audio/__init__.py new file mode 100644 index 00000000..0626eeeb --- /dev/null +++ b/bumble/audio/__init__.py @@ -0,0 +1,17 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# ----------------------------------------------------------------------------- +# Imports +# ----------------------------------------------------------------------------- diff --git a/bumble/audio/io.py b/bumble/audio/io.py new file mode 100644 index 00000000..06e44821 --- /dev/null +++ b/bumble/audio/io.py @@ -0,0 +1,534 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# ----------------------------------------------------------------------------- +# Imports +# ----------------------------------------------------------------------------- +from __future__ import annotations + +import asyncio +import abc +from concurrent.futures import ThreadPoolExecutor +import dataclasses +import enum +import logging +from typing import ( + AsyncGenerator, + BinaryIO, + TYPE_CHECKING, +) +import wave + +if TYPE_CHECKING: + import sounddevice + + +# ----------------------------------------------------------------------------- +# Logging +# ----------------------------------------------------------------------------- +logger = logging.getLogger(__name__) + + +# ----------------------------------------------------------------------------- +# Classes +# ----------------------------------------------------------------------------- +@dataclasses.dataclass +class PcmFormat: + class Endianness(enum.Enum): + LITTLE = 0 + BIG = 1 + + class SampleType(enum.Enum): + FLOAT32 = 0 + INT16 = 1 + + endianness: Endianness + sample_type: SampleType + sample_rate: int + channels: int + + @classmethod + def from_str(cls, format_str: str) -> PcmFormat: + endianness = cls.Endianness.LITTLE # Others not yet supported. + sample_type_str, sample_rate_str, channels_str = format_str.split(',') + if sample_type_str == 'int16le': + sample_type = cls.SampleType.INT16 + elif sample_rate_str == 'float32le': + sample_type = cls.SampleType.FLOAT32 + else: + raise ValueError(f'sample type {sample_type_str} not supported') + sample_rate = int(sample_rate_str) + channels = int(channels_str) + + return cls(endianness, sample_type, sample_rate, channels) + + +def check_audio_output(output: str) -> bool: + if output == 'device' or output.startswith('device:'): + try: + import sounddevice + except ImportError as exc: + raise ValueError( + 'audio output not available (sounddevice python module not installed)' + ) from exc + + if output == 'device': + # Default device + return True + + # Specific device + device = output[7:] + if device == '?': + print(color('Audio Devices:', 'yellow')) + for device_info in [ + device_info + for device_info in sounddevice.query_devices() + if device_info['max_output_channels'] > 0 + ]: + device_index = device_info['index'] + is_default = ( + color(' [default]', 'green') + if sounddevice.default.device[1] == device_index + else '' + ) + print( + f'{color(device_index, "cyan")}: {device_info["name"]}{is_default}' + ) + return False + + try: + device_info = sounddevice.query_devices(int(device)) + except sounddevice.PortAudioError as exc: + raise ValueError('No such audio device') from exc + + if device_info['max_output_channels'] < 1: + raise ValueError( + f'Device {device} ({device_info["name"]}) does not have an output' + ) + + return True + + +async def create_audio_output(output: str) -> AudioOutput: + if output == 'stdout': + return StreamAudioOutput(sys.stdout.buffer) + + if output == 'device' or output.startswith('device:'): + device_name = '' if output == 'device' else output[7:] + return SoundDeviceAudioOutput(device_name) + + if output == 'ffplay': + return SubprocessAudioOutput( + command=( + 'ffplay -probesize 32 -fflags nobuffer -analyzeduration 0 ' + '-ar {sample_rate} ' + '-ch_layout {channel_layout} ' + '-f f32le pipe:0' + ) + ) + + if output.startswith('file:'): + return FileAudioOutput(output[5:]) + + raise ValueError('unsupported audio output') + + +class AudioOutput(abc.ABC): + """Audio output to which PCM samples can be written.""" + + async def open(self, pcm_format: PcmFormat) -> None: + """Start the output.""" + + @abc.abstractmethod + def write(self, pcm_samples: bytes) -> None: + """Write PCM samples. Must not block.""" + + async def aclose(self) -> None: + """Close the output.""" + + +class ThreadedAudioOutput(AudioOutput): + """Base class for AudioOutput classes that may need to call blocking functions. + + The actual writing is performed in a thread, so as to ensure that calling write() + does not block the caller. + """ + + def __init__(self) -> None: + self._thread_pool = ThreadPoolExecutor(1) + self._pcm_samples: asyncio.Queue[bytes] = asyncio.Queue() + self._write_task = asyncio.create_task(self._write_loop()) + + async def _write_loop(self) -> None: + while True: + pcm_samples = await self._pcm_samples.get() + await asyncio.get_running_loop().run_in_executor( + self._thread_pool, self._write, pcm_samples + ) + + @abc.abstractmethod + def _write(self, pcm_samples: bytes) -> None: + """This method does the actual writing and can block.""" + + def write(self, pcm_samples: bytes) -> None: + self._pcm_samples.put_nowait(pcm_samples) + + def _close(self) -> None: + """This method does the actual closing and can block.""" + + async def aclose(self) -> None: + await asyncio.get_running_loop().run_in_executor(self._thread_pool, self._close) + self._write_task.cancel() + self._thread_pool.shutdown() + + +class SoundDeviceAudioOutput(ThreadedAudioOutput): + def __init__(self, device_name: str) -> None: + super().__init__() + self._device = int(device_name) if device_name else None + self._stream: sounddevice.RawOutputStream | None = None + + async def open(self, pcm_format: PcmFormat) -> None: + import sounddevice + + self._stream = sounddevice.RawOutputStream( + samplerate=pcm_format.sample_rate, + device=self._device, + channels=pcm_format.channels, + dtype='float32', + ) + self._stream.start() + + def _write(self, pcm_samples: bytes) -> None: + if self._stream is None: + return + + try: + self._stream.write(pcm_samples) + except Exception as error: + print(f'Sound device error: {error}') + raise + + def _close(self): + self._stream.stop() + self._stream = None + + +class StreamAudioOutput(ThreadedAudioOutput): + """AudioOutput where PCM samples are written to a stream that may block.""" + + def __init__(self, stream: BinaryIO) -> None: + super().__init__() + self._stream = stream + + def _write(self, pcm_samples: bytes) -> None: + self._stream.write(pcm_samples) + self._stream.flush() + + +class FileAudioOutput(StreamAudioOutput): + """AudioOutput where PCM samples are written to a file.""" + + def __init__(self, filename: str) -> None: + self._file = open(filename, "wb") + super().__init__(self._file) + + async def shutdown(self): + self._file.close() + return await super().shutdown() + + +class SubprocessAudioOutput(AudioOutput): + """AudioOutput where audio samples are written to a subprocess via stdin.""" + + def __init__(self, command: str) -> None: + self._command = command + self._subprocess: asyncio.subprocess.Process | None + + async def open(self, pcm_format: PcmFormat) -> None: + if pcm_format.channels == 1: + channel_layout = 'mono' + elif pcm_format.channels == 2: + channel_layout = 'stereo' + else: + raise ValueError(f'{pcm_format.channels} channels not supported') + + command = self._command.format( + sample_rate=pcm_format.sample_rate, channel_layout=channel_layout + ) + self._subprocess = await asyncio.create_subprocess_shell( + command, + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + + def write(self, pcm_samples: bytes) -> None: + if self._subprocess is None or self._subprocess.stdin is None: + return + + self._subprocess.stdin.write(pcm_samples) + + async def aclose(self): + if self._subprocess: + self._subprocess.terminate() + + +def check_audio_input(input: str) -> bool: + if input == 'device' or input.startswith('device:'): + try: + import sounddevice + except ImportError as exc: + raise ValueError( + 'audio input not available (sounddevice python module not installed)' + ) from exc + + if input == 'device': + # Default device + return True + + # Specific device + device = input[7:] + if device == '?': + print(color('Audio Devices:', 'yellow')) + for device_info in [ + device_info + for device_info in sounddevice.query_devices() + if device_info['max_input_channels'] > 0 + ]: + device_index = device_info["index"] + is_mono = device_info['max_input_channels'] == 1 + max_channels = color(f'[{"mono" if is_mono else "stereo"}]', 'cyan') + is_default = ( + color(' [default]', 'green') + if sounddevice.default.device[0] == device_index + else '' + ) + print( + f'{color(device_index, "cyan")}: {device_info["name"]}' + f' {max_channels}{is_default}' + ) + return False + + try: + device_info = sounddevice.query_devices(int(device)) + except sounddevice.PortAudioError as exc: + raise ValueError('No such audio device') from exc + + if device_info['max_input_channels'] < 1: + raise ValueError( + f'Device {device} ({device_info["name"]}) does not have an input' + ) + + return True + + +async def create_audio_input(input: str, input_format: str) -> AudioInput: + pcm_format: PcmFormat | None + if input_format == 'auto': + pcm_format = None + else: + pcm_format = PcmFormat.from_str(input_format) + + if input == 'stdin': + if not pcm_format: + raise ValueError('input format details required for stdin') + return StreamAudioInput(sys.stdin.buffer, pcm_format) + + if input == 'device' or input.startswith('device:'): + if not pcm_format: + raise ValueError('input format details required for device') + device_name = '' if input == 'device' else input[7:] + return SoundDeviceAudioInput(device_name, pcm_format) + + # If there's no file: prefix, check if we can assume it is a file. + if pathlib.Path(input).is_file(): + input = 'file:' + input + + if input.startswith('file:'): + filename = input[5:] + if filename.endswith('.wav'): + if input_format != 'auto': + raise ValueError(".wav file only supported with 'auto' format") + return WaveAudioInput(filename) + + if pcm_format is None: + raise ValueError('input format details required for raw PCM files') + return FileAudioInput(filename, pcm_format) + + raise ValueError('input not supported') + + +class AudioInput(abc.ABC): + """Audio input that produces PCM samples.""" + + @abc.abstractmethod + async def open(self) -> PcmFormat: + """Open the input.""" + + @abc.abstractmethod + def frames(self, frame_size: int) -> AsyncGenerator[bytes]: + """Generate one frame of PCM samples. Must not block.""" + + async def aclose(self) -> None: + """Close the input.""" + + +class ThreadedAudioInput(AudioInput): + """Base class for AudioInput implementation where reading samples may block.""" + + def __init__(self) -> None: + self._thread_pool = ThreadPoolExecutor(1) + self._pcm_samples: asyncio.Queue[bytes] = asyncio.Queue() + + @abc.abstractmethod + def _read(self, frame_size: int) -> bytes: + pass + + @abc.abstractmethod + def _open(self) -> PcmFormat: + pass + + def _close(self) -> None: + pass + + async def open(self) -> PcmFormat: + return await asyncio.get_running_loop().run_in_executor( + self._thread_pool, self._open + ) + + async def frames(self, frame_size: int) -> AsyncGenerator[bytes]: + while pcm_sample := await asyncio.get_running_loop().run_in_executor( + self._thread_pool, self._read, frame_size + ): + yield pcm_sample + + async def aclose(self) -> None: + await asyncio.get_running_loop().run_in_executor(self._thread_pool, self._close) + self._thread_pool.shutdown() + + +class WaveAudioInput(ThreadedAudioInput): + """Audio input that reads PCM samples from a .wav file.""" + + def __init__(self, filename: str) -> None: + super().__init__() + self._filename = filename + self._wav: wave.Wave_read | None = None + self._bytes_read = 0 + + def _open(self) -> PcmFormat: + self._wav = wave.open(self._filename, 'rb') + if self._wav.getsampwidth() != 2: + raise ValueError('sample width not supported') + return PcmFormat( + PcmFormat.Endianness.LITTLE, + PcmFormat.SampleType.INT16, + self._wav.getframerate(), + self._wav.getnchannels(), + ) + + def _read(self, frame_size: int) -> bytes: + if not self._wav: + return b'' + + pcm_samples = self._wav.readframes(frame_size) + if not pcm_samples and self._bytes_read: + # Loop around. + self._wav.rewind() + self._bytes_read = 0 + pcm_samples = self._wav.readframes(frame_size) + + self._bytes_read += len(pcm_samples) + return pcm_samples + + def _close(self) -> None: + if self._wav: + self._wav.close() + + +class StreamAudioInput(ThreadedAudioInput): + """AudioInput where samples are read from a raw PCM stream that may block.""" + + def __init__(self, stream: BinaryIO, pcm_format: PcmFormat) -> None: + super().__init__() + self._stream = stream + self._pcm_format = pcm_format + + def _open(self) -> PcmFormat: + return self._pcm_format + + def _read(self, frame_size: int) -> bytes: + return self._stream.read(frame_size * self._pcm_format.channels * 2) + + +class FileAudioInput(StreamAudioInput): + """AudioInput where PCM samples are read from a raw PCM file.""" + + def __init__(self, filename: str, pcm_format: PcmFormat) -> None: + self._stream = open(filename, "rb") + super().__init__(self._stream, pcm_format) + + def _read(self, frame_size: int) -> bytes: + return self._stream.read(frame_size * self._pcm_format.channels * 2) + + def _close(self) -> None: + self._stream.close() + + +class SoundDeviceAudioInput(ThreadedAudioInput): + def __init__(self, device_name: str, pcm_format: PcmFormat) -> None: + super().__init__() + self._device = int(device_name) if device_name else None + self._pcm_format = pcm_format + self._stream: sounddevice.RawInputStream | None = None + + def _open(self) -> PcmFormat: + import sounddevice + + self._stream = sounddevice.RawInputStream( + samplerate=self._pcm_format.sample_rate, + device=self._device, + channels=self._pcm_format.channels, + dtype='int16', + ) + self._stream.start() + + return PcmFormat( + PcmFormat.Endianness.LITTLE, + PcmFormat.SampleType.INT16, + self._pcm_format.sample_rate, + 2, + ) + + def _read(self, frame_size: int) -> bytes: + if not self._stream: + return b'' + pcm_buffer, overflowed = self._stream.read(frame_size) + if overflowed: + logger.warning("input overflow") + + # Convert the buffer to stereo if needed + if self._pcm_format.channels == 1: + stereo_buffer = bytearray() + for i in range(frame_size): + sample = pcm_buffer[i * 2 : i * 2 + 2] + stereo_buffer += sample + sample + return stereo_buffer + + return bytes(pcm_buffer) + + def _close(self): + self._stream.stop() + self._stream = None From efae307b3d64feffdaf3b62546f76920edda6366 Mon Sep 17 00:00:00 2001 From: Gilles Boccon-Gibod Date: Wed, 5 Feb 2025 16:23:47 -0500 Subject: [PATCH 04/13] wip --- apps/auracast.py | 168 +---------------------------- bumble/audio/io.py | 16 ++- bumble/gatt.py | 1 + examples/run_gatt_with_adapters.py | 10 +- examples/run_vcp_renderer.py | 14 +-- tests/import_test.py | 4 +- 6 files changed, 27 insertions(+), 186 deletions(-) diff --git a/apps/auracast.py b/apps/auracast.py index 9ba84698..37e19b6d 100644 --- a/apps/auracast.py +++ b/apps/auracast.py @@ -25,9 +25,7 @@ import dataclasses import functools import logging import os -import pathlib import struct -import sys from typing import ( cast, Any, @@ -80,164 +78,6 @@ AURACAST_DEFAULT_SAMPLE_RATE = 48000 AURACAST_DEFAULT_TRANSMIT_BITRATE = 80000 -# ----------------------------------------------------------------------------- -# Audio I/O Support -# ----------------------------------------------------------------------------- -def check_audio_output(output: str) -> bool: - if output == 'device' or output.startswith('device:'): - try: - import sounddevice # type: ignore[import-untyped] - except ImportError as exc: - raise ValueError( - 'audio output not available (sounddevice python module not installed)' - ) from exc - - if output == 'device': - # Default device - return True - - # Specific device - device = output[7:] - if device == '?': - print(color('Audio Devices:', 'yellow')) - for device_info in [ - device_info - for device_info in sounddevice.query_devices() - if device_info['max_output_channels'] > 0 - ]: - device_index = device_info['index'] - is_default = ( - color(' [default]', 'green') - if sounddevice.default.device[1] == device_index - else '' - ) - print( - f'{color(device_index, "cyan")}: {device_info["name"]}{is_default}' - ) - return False - - try: - device_info = sounddevice.query_devices(int(device)) - except sounddevice.PortAudioError as exc: - raise ValueError('No such audio device') from exc - - if device_info['max_output_channels'] < 1: - raise ValueError( - f'Device {device} ({device_info["name"]}) does not have an output' - ) - - return True - - -async def create_audio_output(output: str) -> audio_io.AudioOutput: - if output == 'stdout': - return audio_io.StreamAudioOutput(sys.stdout.buffer) - - if output == 'device' or output.startswith('device:'): - device_name = '' if output == 'device' else output[7:] - return audio_io.SoundDeviceAudioOutput(device_name) - - if output == 'ffplay': - return audio_io.SubprocessAudioOutput( - command=( - 'ffplay -probesize 32 -fflags nobuffer -analyzeduration 0 ' - '-ar {sample_rate} ' - '-ch_layout {channel_layout} ' - '-f f32le pipe:0' - ) - ) - - if output.startswith('file:'): - return audio_io.FileAudioOutput(output[5:]) - - raise ValueError('unsupported audio output') - - -def check_audio_input(input: str) -> bool: - if input == 'device' or input.startswith('device:'): - try: - import sounddevice - except ImportError as exc: - raise ValueError( - 'audio input not available (sounddevice python module not installed)' - ) from exc - - if input == 'device': - # Default device - return True - - # Specific device - device = input[7:] - if device == '?': - print(color('Audio Devices:', 'yellow')) - for device_info in [ - device_info - for device_info in sounddevice.query_devices() - if device_info['max_input_channels'] > 0 - ]: - device_index = device_info["index"] - is_mono = device_info['max_input_channels'] == 1 - max_channels = color(f'[{"mono" if is_mono else "stereo"}]', 'cyan') - is_default = ( - color(' [default]', 'green') - if sounddevice.default.device[0] == device_index - else '' - ) - print( - f'{color(device_index, "cyan")}: {device_info["name"]}' - f' {max_channels}{is_default}' - ) - return False - - try: - device_info = sounddevice.query_devices(int(device)) - except sounddevice.PortAudioError as exc: - raise ValueError('No such audio device') from exc - - if device_info['max_input_channels'] < 1: - raise ValueError( - f'Device {device} ({device_info["name"]}) does not have an input' - ) - - return True - - -async def create_audio_input(input: str, input_format: str) -> audio_io.AudioInput: - pcm_format: audio_io.PcmFormat | None - if input_format == 'auto': - pcm_format = None - else: - pcm_format = audio_io.PcmFormat.from_str(input_format) - - if input == 'stdin': - if not pcm_format: - raise ValueError('input format details required for stdin') - return audio_io.StreamAudioInput(sys.stdin.buffer, pcm_format) - - if input == 'device' or input.startswith('device:'): - if not pcm_format: - raise ValueError('input format details required for device') - device_name = '' if input == 'device' else input[7:] - return audio_io.SoundDeviceAudioInput(device_name, pcm_format) - - # If there's no file: prefix, check if we can assume it is a file. - if pathlib.Path(input).is_file(): - input = 'file:' + input - - if input.startswith('file:'): - filename = input[5:] - if filename.endswith('.wav'): - if input_format != 'auto': - raise ValueError(".wav file only supported with 'auto' format") - return audio_io.WaveAudioInput(filename) - - if pcm_format is None: - raise ValueError('input format details required for raw PCM files') - return audio_io.FileAudioInput(filename, pcm_format) - - raise ValueError('input not supported') - - # ----------------------------------------------------------------------------- # Utils # ----------------------------------------------------------------------------- @@ -833,7 +673,7 @@ async def run_receive( ) -> None: # Run a pre-flight check for the output. try: - if not check_audio_output(output): + if not audio_io.check_audio_output(output): return except ValueError as error: print(error) @@ -907,7 +747,7 @@ async def run_receive( packet_stats = [0, 0] async with contextlib.aclosing( - await create_audio_output(output) + await audio_io.create_audio_output(output) ) as audio_output: await audio_output.open( audio_io.PcmFormat( @@ -978,7 +818,7 @@ async def run_transmit( ) -> None: # Run a pre-flight check for the input. try: - if not check_audio_input(input): + if not audio_io.check_audio_input(input): return except ValueError as error: print(error) @@ -1073,7 +913,7 @@ async def run_transmit( print('Start Periodic Advertising') await advertising_set.start_periodic() - audio_input = await create_audio_input(input, input_format) + audio_input = await audio_io.create_audio_input(input, input_format) pcm_format = await audio_input.open() if pcm_format.channels != 2: print("Only 2 channels PCM configurations are supported") diff --git a/bumble/audio/io.py b/bumble/audio/io.py index 06e44821..7bc2b40a 100644 --- a/bumble/audio/io.py +++ b/bumble/audio/io.py @@ -23,15 +23,19 @@ from concurrent.futures import ThreadPoolExecutor import dataclasses import enum import logging +import pathlib from typing import ( AsyncGenerator, BinaryIO, TYPE_CHECKING, ) +import sys import wave +from bumble.colors import color + if TYPE_CHECKING: - import sounddevice + import sounddevice # type: ignore[import-untyped] # ----------------------------------------------------------------------------- @@ -78,9 +82,10 @@ def check_audio_output(output: str) -> bool: if output == 'device' or output.startswith('device:'): try: import sounddevice - except ImportError as exc: + except (ImportError, OSError) as exc: raise ValueError( - 'audio output not available (sounddevice python module not installed)' + 'audio output not available ' + '(sounddevice python module not installed or failed to load)' ) from exc if output == 'device': @@ -289,9 +294,10 @@ def check_audio_input(input: str) -> bool: if input == 'device' or input.startswith('device:'): try: import sounddevice - except ImportError as exc: + except (ImportError, OSError) as exc: raise ValueError( - 'audio input not available (sounddevice python module not installed)' + 'audio input not available ' + '(sounddevice python module not installed or failed to load)' ) from exc if input == 'device': diff --git a/bumble/gatt.py b/bumble/gatt.py index 8192f87e..f237b805 100644 --- a/bumble/gatt.py +++ b/bumble/gatt.py @@ -315,6 +315,7 @@ GATT_CENTRAL_ADDRESS_RESOLUTION__CHARACTERISTIC = UUID.from_16_bi GATT_CLIENT_SUPPORTED_FEATURES_CHARACTERISTIC = UUID.from_16_bits(0x2B29, 'Client Supported Features') GATT_DATABASE_HASH_CHARACTERISTIC = UUID.from_16_bits(0x2B2A, 'Database Hash') GATT_SERVER_SUPPORTED_FEATURES_CHARACTERISTIC = UUID.from_16_bits(0x2B3A, 'Server Supported Features') +GATT_LE_GATT_SECURITY_LEVELS_CHARACTERISTIC = UUID.from_16_bits(0x2BF5, 'E GATT Security Levels') # fmt: on # pylint: enable=line-too-long diff --git a/examples/run_gatt_with_adapters.py b/examples/run_gatt_with_adapters.py index f5430b8e..97fb8917 100644 --- a/examples/run_gatt_with_adapters.py +++ b/examples/run_gatt_with_adapters.py @@ -25,7 +25,7 @@ import struct import sys from typing import Any, List, Union -from bumble.device import Connection, Device, Peer +from bumble.device import Device, Peer from bumble import transport from bumble import gatt from bumble import hci @@ -82,19 +82,19 @@ async def client(device: Device, address: hci.Address) -> None: for index in range(1, 9): characteristics.append( service.get_characteristics_by_uuid( - CHARACTERISTIC_UUID_BASE + f"{index:02X}" + core.UUID(CHARACTERISTIC_UUID_BASE + f"{index:02X}") )[0] ) # Read all characteristics as raw bytes. for characteristic in characteristics: value = await characteristic.read_value() - print(f"### {characteristic} = {value} ({value.hex()})") + print(f"### {characteristic} = {value!r} ({value.hex()})") # Static characteristic with a bytes value. c1 = characteristics[0] c1_value = await c1.read_value() - print(f"@@@ C1 {c1} value = {c1_value} (type={type(c1_value)})") + print(f"@@@ C1 {c1} value = {c1_value!r} (type={type(c1_value)})") await c1.write_value("happy π day".encode("utf-8")) # Static characteristic with a string value. @@ -136,7 +136,7 @@ async def client(device: Device, address: hci.Address) -> None: # Dynamic characteristic with a bytes value. c7 = characteristics[6] c7_value = await c7.read_value() - print(f"@@@ C7 {c7} value = {c7_value} (type={type(c7_value)})") + print(f"@@@ C7 {c7} value = {c7_value!r} (type={type(c7_value)})") await c7.write_value(bytes.fromhex("01020304")) # Dynamic characteristic with a string value. diff --git a/examples/run_vcp_renderer.py b/examples/run_vcp_renderer.py index 1002bc69..116d4d66 100644 --- a/examples/run_vcp_renderer.py +++ b/examples/run_vcp_renderer.py @@ -174,16 +174,10 @@ async def main() -> None: ws = websocket async for message in websocket: volume_state = json.loads(message) - vcs.volume_state_bytes = bytes( - [ - volume_state['volume_setting'], - volume_state['muted'], - volume_state['change_counter'], - ] - ) - await device.notify_subscribers( - vcs.volume_state, vcs.volume_state_bytes - ) + vcs.volume_setting = volume_state['volume_setting'] + vcs.muted = volume_state['muted'] + vcs.change_counter = volume_state['change_counter'] + await device.notify_subscribers(vcs.volume_state) ws = None await websockets.serve(serve, 'localhost', 8989) diff --git a/tests/import_test.py b/tests/import_test.py index 95425112..b868c0da 100644 --- a/tests/import_test.py +++ b/tests/import_test.py @@ -53,7 +53,7 @@ def test_import(): le_audio, pacs, pbp, - vcp, + vcs, ) assert att @@ -87,7 +87,7 @@ def test_import(): assert le_audio assert pacs assert pbp - assert vcp + assert vcs # ----------------------------------------------------------------------------- From 7f5e0d190e3ba472a09bf5e2b5e1f5eda872a3c9 Mon Sep 17 00:00:00 2001 From: Gilles Boccon-Gibod Date: Wed, 5 Feb 2025 22:19:39 -0500 Subject: [PATCH 05/13] fix import checking --- apps/auracast.py | 4 ++-- bumble/audio/io.py | 18 ++++++++++++------ 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/apps/auracast.py b/apps/auracast.py index 37e19b6d..e4d35937 100644 --- a/apps/auracast.py +++ b/apps/auracast.py @@ -675,7 +675,7 @@ async def run_receive( try: if not audio_io.check_audio_output(output): return - except ValueError as error: + except (ValueError, ImportError, OSError) as error: print(error) return @@ -820,7 +820,7 @@ async def run_transmit( try: if not audio_io.check_audio_input(input): return - except ValueError as error: + except (ValueError, ImportError, OSError) as error: print(error) return diff --git a/bumble/audio/io.py b/bumble/audio/io.py index 7bc2b40a..f6566a2d 100644 --- a/bumble/audio/io.py +++ b/bumble/audio/io.py @@ -82,10 +82,13 @@ def check_audio_output(output: str) -> bool: if output == 'device' or output.startswith('device:'): try: import sounddevice - except (ImportError, OSError) as exc: + except ImportError as exc: raise ValueError( - 'audio output not available ' - '(sounddevice python module not installed or failed to load)' + 'audio output not available (sounddevice python module not installed)' + ) from exc + except OSError as exc: + raise ValueError( + 'audio output not available (sounddevice python module failed to load)' ) from exc if output == 'device': @@ -294,10 +297,13 @@ def check_audio_input(input: str) -> bool: if input == 'device' or input.startswith('device:'): try: import sounddevice - except (ImportError, OSError) as exc: + except ImportError as exc: raise ValueError( - 'audio input not available ' - '(sounddevice python module not installed or failed to load)' + 'audio input not available (sounddevice python module not installed)' + ) from exc + except OSError as exc: + raise ValueError( + 'audio input not available (sounddevice python module failed to load)' ) from exc if input == 'device': From 26e87f09fe36ea3062a332b2521854e8ad4ccd21 Mon Sep 17 00:00:00 2001 From: Gilles Boccon-Gibod Date: Wed, 5 Feb 2025 22:28:05 -0500 Subject: [PATCH 06/13] better error message --- apps/auracast.py | 4 ++-- bumble/audio/io.py | 8 ++++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/apps/auracast.py b/apps/auracast.py index e4d35937..37e19b6d 100644 --- a/apps/auracast.py +++ b/apps/auracast.py @@ -675,7 +675,7 @@ async def run_receive( try: if not audio_io.check_audio_output(output): return - except (ValueError, ImportError, OSError) as error: + except ValueError as error: print(error) return @@ -820,7 +820,7 @@ async def run_transmit( try: if not audio_io.check_audio_input(input): return - except (ValueError, ImportError, OSError) as error: + except ValueError as error: print(error) return diff --git a/bumble/audio/io.py b/bumble/audio/io.py index f6566a2d..702e991c 100644 --- a/bumble/audio/io.py +++ b/bumble/audio/io.py @@ -88,7 +88,9 @@ def check_audio_output(output: str) -> bool: ) from exc except OSError as exc: raise ValueError( - 'audio output not available (sounddevice python module failed to load)' + 'audio output not available ' + '(sounddevice python module failed to load: ' + f'{exc})' ) from exc if output == 'device': @@ -303,7 +305,9 @@ def check_audio_input(input: str) -> bool: ) from exc except OSError as exc: raise ValueError( - 'audio input not available (sounddevice python module failed to load)' + 'audio input not available ' + '(sounddevice python module failed to load: ' + f'{exc})' ) from exc if input == 'device': From 33435c298063827fcf05552543f2ef8d6c3b5e88 Mon Sep 17 00:00:00 2001 From: Gilles Boccon-Gibod Date: Thu, 6 Feb 2025 15:48:39 -0500 Subject: [PATCH 07/13] better docs and GATT fixes --- apps/auracast.py | 2 +- bumble/gatt.py | 10 ++-- bumble/profiles/vocs.py | 101 +++++++++++++++++----------------------- tests/vocs_test.py | 11 ++--- 4 files changed, 54 insertions(+), 70 deletions(-) diff --git a/apps/auracast.py b/apps/auracast.py index 37e19b6d..f51dfa91 100644 --- a/apps/auracast.py +++ b/apps/auracast.py @@ -1195,7 +1195,7 @@ def transmit( input, input_format, ): - """Transmit a broadcast source.""" + """Transmit a broadcast source""" if manufacturer_data: vendor_id_str, data_hex = manufacturer_data.split(':') vendor_id = int(vendor_id_str) diff --git a/bumble/gatt.py b/bumble/gatt.py index f237b805..b3397ae9 100644 --- a/bumble/gatt.py +++ b/bumble/gatt.py @@ -42,7 +42,7 @@ from typing import ( ) from bumble.colors import color -from bumble.core import BaseBumbleError, UUID +from bumble.core import BaseBumbleError, InvalidOperationError, UUID from bumble.att import Attribute, AttributeValue from bumble.utils import ByteSerializable @@ -679,10 +679,14 @@ class DelegatedCharacteristicAdapter(CharacteristicAdapter): self.decode = decode def encode_value(self, value): - return self.encode(value) if self.encode else value + if self.encode is None: + raise InvalidOperationError('delegated adapter does not have an encoder') + return self.encode(value) def decode_value(self, value): - return self.decode(value) if self.decode else value + if self.decode is None: + raise InvalidOperationError('delegate adapter does not have a decoder') + return self.decode(value) # ----------------------------------------------------------------------------- diff --git a/bumble/profiles/vocs.py b/bumble/profiles/vocs.py index 70764fba..af5447fe 100644 --- a/bumble/profiles/vocs.py +++ b/bumble/profiles/vocs.py @@ -82,9 +82,7 @@ class VolumeOffsetState: async def notify_subscribers_via_connection(self, connection: Connection) -> None: assert self.attribute_value is not None - await connection.device.notify_subscribers( - attribute=self.attribute_value, value=bytes(self) - ) + await connection.device.notify_subscribers(attribute=self.attribute_value) def on_read(self, _connection: Optional[Connection]) -> bytes: return bytes(self) @@ -111,9 +109,7 @@ class VocsAudioLocation: assert self.attribute_value self.audio_location = AudioLocation(int.from_bytes(value, 'little')) - await connection.device.notify_subscribers( - attribute=self.attribute_value, value=value - ) + await connection.device.notify_subscribers(attribute=self.attribute_value) @dataclass @@ -169,9 +165,7 @@ class AudioOutputDescription: assert self.attribute_value self.audio_output_description = value.decode('utf-8') - await connection.device.notify_subscribers( - attribute=self.attribute_value, value=value - ) + await connection.device.notify_subscribers(attribute=self.attribute_value) # ----------------------------------------------------------------------------- @@ -203,37 +197,30 @@ class VolumeOffsetControlService(TemplateService): VolumeOffsetControlPoint(self.volume_offset_state) ) - self.volume_offset_state_characteristic = DelegatedCharacteristicAdapter( - Characteristic( - uuid=GATT_VOLUME_OFFSET_STATE_CHARACTERISTIC, - properties=( - Characteristic.Properties.READ | Characteristic.Properties.NOTIFY - ), - permissions=Characteristic.Permissions.READ_REQUIRES_ENCRYPTION, - value=CharacteristicValue(read=self.volume_offset_state.on_read), + self.volume_offset_state_characteristic = Characteristic( + uuid=GATT_VOLUME_OFFSET_STATE_CHARACTERISTIC, + properties=( + Characteristic.Properties.READ | Characteristic.Properties.NOTIFY ), - encode=lambda value: bytes(value), + permissions=Characteristic.Permissions.READ_REQUIRES_ENCRYPTION, + value=CharacteristicValue(read=self.volume_offset_state.on_read), ) - self.audio_location_characteristic = DelegatedCharacteristicAdapter( - Characteristic( - uuid=GATT_AUDIO_LOCATION_CHARACTERISTIC, - properties=( - Characteristic.Properties.READ - | Characteristic.Properties.NOTIFY - | Characteristic.Properties.WRITE_WITHOUT_RESPONSE - ), - permissions=( - Characteristic.Permissions.READ_REQUIRES_ENCRYPTION - | Characteristic.Permissions.WRITE_REQUIRES_ENCRYPTION - ), - value=CharacteristicValue( - read=self.audio_location.on_read, - write=self.audio_location.on_write, - ), + self.audio_location_characteristic = Characteristic( + uuid=GATT_AUDIO_LOCATION_CHARACTERISTIC, + properties=( + Characteristic.Properties.READ + | Characteristic.Properties.NOTIFY + | Characteristic.Properties.WRITE_WITHOUT_RESPONSE + ), + permissions=( + Characteristic.Permissions.READ_REQUIRES_ENCRYPTION + | Characteristic.Permissions.WRITE_REQUIRES_ENCRYPTION + ), + value=CharacteristicValue( + read=self.audio_location.on_read, + write=self.audio_location.on_write, ), - encode=lambda value: bytes(value), - decode=VocsAudioLocation.from_bytes, ) self.audio_location.attribute_value = self.audio_location_characteristic.value @@ -244,25 +231,22 @@ class VolumeOffsetControlService(TemplateService): value=CharacteristicValue(write=self.volume_offset_control_point.on_write), ) - self.audio_output_description_characteristic = DelegatedCharacteristicAdapter( - Characteristic( - uuid=GATT_AUDIO_OUTPUT_DESCRIPTION_CHARACTERISTIC, - properties=( - Characteristic.Properties.READ - | Characteristic.Properties.NOTIFY - | Characteristic.Properties.WRITE_WITHOUT_RESPONSE - ), - permissions=( - Characteristic.Permissions.READ_REQUIRES_ENCRYPTION - | Characteristic.Permissions.WRITE_REQUIRES_ENCRYPTION - ), - value=CharacteristicValue( - read=self.audio_output_description.on_read, - write=self.audio_output_description.on_write, - ), - ) + self.audio_output_description_characteristic = Characteristic( + uuid=GATT_AUDIO_OUTPUT_DESCRIPTION_CHARACTERISTIC, + properties=( + Characteristic.Properties.READ + | Characteristic.Properties.NOTIFY + | Characteristic.Properties.WRITE_WITHOUT_RESPONSE + ), + permissions=( + Characteristic.Permissions.READ_REQUIRES_ENCRYPTION + | Characteristic.Permissions.WRITE_REQUIRES_ENCRYPTION + ), + value=CharacteristicValue( + read=self.audio_output_description.on_read, + write=self.audio_output_description.on_write, + ), ) - self.audio_output_description.attribute_value = ( self.audio_output_description_characteristic.value ) @@ -287,18 +271,19 @@ class VolumeOffsetControlServiceProxy(ProfileServiceProxy): def __init__(self, service_proxy: ServiceProxy) -> None: self.service_proxy = service_proxy - self.volume_offset_state = DelegatedCharacteristicAdapter( + self.volume_offset_state = SerializableCharacteristicAdapter( service_proxy.get_required_characteristic_by_uuid( GATT_VOLUME_OFFSET_STATE_CHARACTERISTIC ), - decode=VolumeOffsetState.from_bytes, + VolumeOffsetState, ) - self.audio_location = SerializableCharacteristicAdapter( + self.audio_location = DelegatedCharacteristicAdapter( service_proxy.get_required_characteristic_by_uuid( GATT_AUDIO_LOCATION_CHARACTERISTIC ), - VocsAudioLocation, + encode=lambda value: bytes([int(value)]), + decode=lambda data: AudioLocation(data[0]), ) self.volume_offset_control_point = ( diff --git a/tests/vocs_test.py b/tests/vocs_test.py index 683654a9..599fc6d1 100644 --- a/tests/vocs_test.py +++ b/tests/vocs_test.py @@ -32,7 +32,6 @@ from bumble.profiles.vocs import ( SetVolumeOffsetOpCode, VolumeOffsetControlServiceProxy, VolumeOffsetState, - VocsAudioLocation, ) from bumble.profiles.vcs import VolumeControlService, VolumeControlServiceProxy from bumble.profiles.bap import AudioLocation @@ -81,9 +80,7 @@ async def test_init_service(vocs_client: VolumeOffsetControlServiceProxy): volume_offset=0, change_counter=0, ) - assert await vocs_client.audio_location.read_value() == VocsAudioLocation( - audio_location=AudioLocation.NOT_ALLOWED - ) + assert await vocs_client.audio_location.read_value() == AudioLocation.NOT_ALLOWED description = await vocs_client.audio_output_description.read_value() assert description == '' @@ -162,11 +159,9 @@ async def test_set_volume_offset(vocs_client: VolumeOffsetControlServiceProxy): @pytest.mark.asyncio async def test_set_audio_channel_location(vocs_client: VolumeOffsetControlServiceProxy): - new_audio_location = VocsAudioLocation(audio_location=AudioLocation.FRONT_LEFT) + new_audio_location = AudioLocation.FRONT_LEFT - await vocs_client.audio_location.write_value( - struct.pack(' Date: Thu, 6 Feb 2025 16:45:37 -0500 Subject: [PATCH 08/13] remove obsolete code --- apps/auracast.py | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/apps/auracast.py b/apps/auracast.py index f51dfa91..89bf8a4a 100644 --- a/apps/auracast.py +++ b/apps/auracast.py @@ -960,24 +960,12 @@ async def run_transmit( async with contextlib.aclosing(audio_input): frame_count = 0 - # start_time = time.time() async for pcm_frame in audio_input.frames(lc3_frame_samples): - # now = time.time() - # if ( - # target_time := ( - # start_time - # + frame_count * AURACAST_DEFAULT_FRAME_DURATION / 1_000_000 - # ) - # ) >= now: - # await asyncio.sleep(target_time - now) - lc3_frame = encoder.encode( pcm_frame, num_bytes=2 * lc3_frame_size, bit_depth=16 ) mid = len(lc3_frame) // 2 - # big.bis_links[0].write(lc3_frame[:mid]) - # big.bis_links[1].write(lc3_frame[mid:]) await iso_queues[0].write(lc3_frame[:mid]) await iso_queues[1].write(lc3_frame[mid:]) From 5caa7bfa9096541de1247b5bc93cdc822867cf1c Mon Sep 17 00:00:00 2001 From: Gilles Boccon-Gibod Date: Thu, 6 Feb 2025 17:05:56 -0500 Subject: [PATCH 09/13] fix type checker and linter errors --- bumble/audio/io.py | 6 +++--- pyproject.toml | 4 ++++ 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/bumble/audio/io.py b/bumble/audio/io.py index 702e991c..4a00f887 100644 --- a/bumble/audio/io.py +++ b/bumble/audio/io.py @@ -210,7 +210,7 @@ class SoundDeviceAudioOutput(ThreadedAudioOutput): self._stream: sounddevice.RawOutputStream | None = None async def open(self, pcm_format: PcmFormat) -> None: - import sounddevice + import sounddevice # pylint: disable=import-error self._stream = sounddevice.RawOutputStream( samplerate=pcm_format.sample_rate, @@ -298,7 +298,7 @@ class SubprocessAudioOutput(AudioOutput): def check_audio_input(input: str) -> bool: if input == 'device' or input.startswith('device:'): try: - import sounddevice + import sounddevice # pylint: disable=import-error except ImportError as exc: raise ValueError( 'audio input not available (sounddevice python module not installed)' @@ -511,7 +511,7 @@ class SoundDeviceAudioInput(ThreadedAudioInput): self._stream: sounddevice.RawInputStream | None = None def _open(self) -> PcmFormat: - import sounddevice + import sounddevice # pylint: disable=import-error self._stream = sounddevice.RawInputStream( samplerate=self._pcm_format.sample_rate, diff --git a/pyproject.toml b/pyproject.toml index 5d916168..872085a8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -192,6 +192,10 @@ ignore_missing_imports = true module = "serial_asyncio.*" ignore_missing_imports = true +[[tool.mypy.overrides]] +module = "sounddevice.*" +ignore_missing_imports = true + [[tool.mypy.overrides]] module = "usb.*" ignore_missing_imports = true From b6adc293658968e2d75f4382bb88a2972f712e56 Mon Sep 17 00:00:00 2001 From: Gilles Boccon-Gibod Date: Thu, 6 Feb 2025 18:17:13 -0500 Subject: [PATCH 10/13] python 3.9 compat --- apps/auracast.py | 91 ++++++++++++++++++++++++++---------------------- 1 file changed, 49 insertions(+), 42 deletions(-) diff --git a/apps/auracast.py b/apps/auracast.py index 89bf8a4a..febf6ea8 100644 --- a/apps/auracast.py +++ b/apps/auracast.py @@ -746,9 +746,10 @@ async def run_receive( lc3_queues: list[Deque[bytes]] = [collections.deque() for i in range(num_bis)] packet_stats = [0, 0] - async with contextlib.aclosing( - await audio_io.create_audio_output(output) - ) as audio_output: + audio_output = await audio_io.create_audio_output(output) + # This try should be replaced with contextlib.aclosing() when python 3.9 is no + # longer needed. + try: await audio_output.open( audio_io.PcmFormat( audio_io.PcmFormat.Endianness.LITTLE, @@ -804,6 +805,8 @@ async def run_receive( terminated = asyncio.Event() big_sync.on(big_sync.Event.TERMINATION, lambda _: terminated.set()) await terminated.wait() + finally: + await audio_output.aclose() async def run_transmit( @@ -915,50 +918,52 @@ async def run_transmit( audio_input = await audio_io.create_audio_input(input, input_format) pcm_format = await audio_input.open() - if pcm_format.channels != 2: - print("Only 2 channels PCM configurations are supported") - return - encoder = lc3.Encoder( - frame_duration_us=AURACAST_DEFAULT_FRAME_DURATION, - sample_rate_hz=AURACAST_DEFAULT_SAMPLE_RATE, - num_channels=pcm_format.channels, - input_sample_rate_hz=pcm_format.sample_rate, - ) - lc3_frame_samples = encoder.get_frame_samples() - lc3_frame_size = encoder.get_frame_bytes(bitrate) + # This try should be replaced with contextlib.aclosing() when python 3.9 is no + # longer needed. + try: + if pcm_format.channels != 2: + print("Only 2 channels PCM configurations are supported") + return + encoder = lc3.Encoder( + frame_duration_us=AURACAST_DEFAULT_FRAME_DURATION, + sample_rate_hz=AURACAST_DEFAULT_SAMPLE_RATE, + num_channels=pcm_format.channels, + input_sample_rate_hz=pcm_format.sample_rate, + ) + lc3_frame_samples = encoder.get_frame_samples() + lc3_frame_size = encoder.get_frame_bytes(bitrate) - print('Setup BIG') - big = await device.create_big( - advertising_set, - parameters=bumble.device.BigParameters( - num_bis=pcm_format.channels, - sdu_interval=AURACAST_DEFAULT_FRAME_DURATION, - max_sdu=lc3_frame_size, - max_transport_latency=65, - rtn=4, - broadcast_code=( - bytes.fromhex(broadcast_code) if broadcast_code else None + print('Setup BIG') + big = await device.create_big( + advertising_set, + parameters=bumble.device.BigParameters( + num_bis=pcm_format.channels, + sdu_interval=AURACAST_DEFAULT_FRAME_DURATION, + max_sdu=lc3_frame_size, + max_transport_latency=65, + rtn=4, + broadcast_code=( + bytes.fromhex(broadcast_code) if broadcast_code else None + ), ), - ), - ) - - iso_queues = [ - bumble.device.IsoPacketStream(big.bis_links[0], 2), - bumble.device.IsoPacketStream(big.bis_links[1], 2), - ] - - def on_flow(): - data_packet_queue = iso_queues[0].data_packet_queue - print( - f'\rPACKETS: pending={data_packet_queue.pending}, ' - f'queued={data_packet_queue.queued}, ' - f'completed={data_packet_queue.completed}', - end='', ) - iso_queues[0].data_packet_queue.on('flow', on_flow) + iso_queues = [ + bumble.device.IsoPacketStream(big.bis_links[0], 5), + bumble.device.IsoPacketStream(big.bis_links[1], 5), + ] + + def on_flow(): + data_packet_queue = iso_queues[0].data_packet_queue + print( + f'\rPACKETS: pending={data_packet_queue.pending}, ' + f'queued={data_packet_queue.queued}, ' + f'completed={data_packet_queue.completed}', + end='', + ) + + iso_queues[0].data_packet_queue.on('flow', on_flow) - async with contextlib.aclosing(audio_input): frame_count = 0 async for pcm_frame in audio_input.frames(lc3_frame_samples): lc3_frame = encoder.encode( @@ -970,6 +975,8 @@ async def run_transmit( await iso_queues[1].write(lc3_frame[mid:]) frame_count += 1 + finally: + await audio_input.aclose() def run_async(async_command: Coroutine) -> None: From dbf94c8f3ef5c0bd0b09a6b8416a73924f3f0ff2 Mon Sep 17 00:00:00 2001 From: Gilles Boccon-Gibod Date: Thu, 6 Feb 2025 18:23:31 -0500 Subject: [PATCH 11/13] print encoding params --- apps/auracast.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/auracast.py b/apps/auracast.py index febf6ea8..76b901b1 100644 --- a/apps/auracast.py +++ b/apps/auracast.py @@ -932,6 +932,10 @@ async def run_transmit( ) lc3_frame_samples = encoder.get_frame_samples() lc3_frame_size = encoder.get_frame_bytes(bitrate) + print( + f'Encoding with {lc3_frame_samples} ' + f'PCM samples per {lc3_frame_size} byte frame' + ) print('Setup BIG') big = await device.create_big( From 5f2d24570e420e9db90d7e3829a45885c3a9a1d6 Mon Sep 17 00:00:00 2001 From: Gilles Boccon-Gibod Date: Thu, 6 Feb 2025 21:24:58 -0500 Subject: [PATCH 12/13] larger queue size --- apps/auracast.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/auracast.py b/apps/auracast.py index 76b901b1..5a8d9582 100644 --- a/apps/auracast.py +++ b/apps/auracast.py @@ -953,8 +953,8 @@ async def run_transmit( ) iso_queues = [ - bumble.device.IsoPacketStream(big.bis_links[0], 5), - bumble.device.IsoPacketStream(big.bis_links[1], 5), + bumble.device.IsoPacketStream(big.bis_links[0], 64), + bumble.device.IsoPacketStream(big.bis_links[1], 64), ] def on_flow(): From 859bb0609fa497fe8498e98e28b58f81aa63e8fc Mon Sep 17 00:00:00 2001 From: Gilles Boccon-Gibod Date: Sat, 8 Feb 2025 18:12:45 -0500 Subject: [PATCH 13/13] fix support for float32 --- apps/auracast.py | 13 +++++++++++-- bumble/audio/io.py | 13 ++++++++----- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/apps/auracast.py b/apps/auracast.py index 5a8d9582..77e58ed4 100644 --- a/apps/auracast.py +++ b/apps/auracast.py @@ -924,6 +924,14 @@ async def run_transmit( if pcm_format.channels != 2: print("Only 2 channels PCM configurations are supported") return + if pcm_format.sample_type == audio_io.PcmFormat.SampleType.INT16: + pcm_bit_depth = 16 + elif pcm_format.sample_type == audio_io.PcmFormat.SampleType.FLOAT32: + pcm_bit_depth = None + else: + print("Only INT16 and FLOAT32 sample types are supported") + return + encoder = lc3.Encoder( frame_duration_us=AURACAST_DEFAULT_FRAME_DURATION, sample_rate_hz=AURACAST_DEFAULT_SAMPLE_RATE, @@ -971,7 +979,7 @@ async def run_transmit( frame_count = 0 async for pcm_frame in audio_input.frames(lc3_frame_samples): lc3_frame = encoder.encode( - pcm_frame, num_bytes=2 * lc3_frame_size, bit_depth=16 + pcm_frame, num_bytes=2 * lc3_frame_size, bit_depth=pcm_bit_depth ) mid = len(lc3_frame) // 2 @@ -1150,7 +1158,8 @@ def receive( "Use 'auto' for .wav files, or for the default setting with the devices. " "For other inputs, the format is specified as " ",, (supported : 'int16le' " - "for 16 bit signed integers with little-endian byte order)" + "for 16-bit signed integers with little-endian byte order or 'float32le' for " + "32-bit floating point with little-endian byte order)" ), ) @click.option( diff --git a/bumble/audio/io.py b/bumble/audio/io.py index 4a00f887..e7c7fe26 100644 --- a/bumble/audio/io.py +++ b/bumble/audio/io.py @@ -68,7 +68,7 @@ class PcmFormat: sample_type_str, sample_rate_str, channels_str = format_str.split(',') if sample_type_str == 'int16le': sample_type = cls.SampleType.INT16 - elif sample_rate_str == 'float32le': + elif sample_type_str == 'float32le': sample_type = cls.SampleType.FLOAT32 else: raise ValueError(f'sample type {sample_type_str} not supported') @@ -77,6 +77,10 @@ class PcmFormat: return cls(endianness, sample_type, sample_rate, channels) + @property + def bytes_per_sample(self) -> int: + return 2 if self.sample_type == self.SampleType.INT16 else 4 + def check_audio_output(output: str) -> bool: if output == 'device' or output.startswith('device:'): @@ -486,7 +490,9 @@ class StreamAudioInput(ThreadedAudioInput): return self._pcm_format def _read(self, frame_size: int) -> bytes: - return self._stream.read(frame_size * self._pcm_format.channels * 2) + return self._stream.read( + frame_size * self._pcm_format.channels * self._pcm_format.bytes_per_sample + ) class FileAudioInput(StreamAudioInput): @@ -496,9 +502,6 @@ class FileAudioInput(StreamAudioInput): self._stream = open(filename, "rb") super().__init__(self._stream, pcm_format) - def _read(self, frame_size: int) -> bytes: - return self._stream.read(frame_size * self._pcm_format.channels * 2) - def _close(self) -> None: self._stream.close()