This commit is contained in:
Gilles Boccon-Gibod
2025-02-05 16:23:47 -05:00
parent 9756572c93
commit efae307b3d
6 changed files with 27 additions and 186 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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