bumble 0.0.204__py3-none-any.whl → 0.0.207__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- bumble/_version.py +2 -2
- bumble/apps/auracast.py +626 -87
- bumble/apps/bench.py +225 -147
- bumble/apps/controller_info.py +23 -7
- bumble/apps/device_info.py +50 -4
- bumble/apps/lea_unicast/app.py +61 -201
- bumble/audio/__init__.py +17 -0
- bumble/audio/io.py +553 -0
- bumble/controller.py +24 -9
- bumble/core.py +4 -1
- bumble/device.py +993 -48
- bumble/gatt.py +35 -6
- bumble/gatt_client.py +14 -2
- bumble/hci.py +812 -14
- bumble/host.py +359 -63
- bumble/l2cap.py +3 -16
- bumble/profiles/aics.py +19 -38
- bumble/profiles/ascs.py +6 -18
- bumble/profiles/asha.py +5 -5
- bumble/profiles/bass.py +10 -19
- bumble/profiles/gatt_service.py +166 -0
- bumble/profiles/gmap.py +193 -0
- bumble/profiles/le_audio.py +87 -4
- bumble/profiles/pacs.py +48 -16
- bumble/profiles/tmap.py +3 -9
- bumble/profiles/{vcp.py → vcs.py} +33 -28
- bumble/profiles/vocs.py +54 -85
- bumble/sdp.py +223 -93
- bumble/smp.py +1 -1
- bumble/utils.py +2 -2
- bumble/vendor/android/hci.py +1 -1
- {bumble-0.0.204.dist-info → bumble-0.0.207.dist-info}/METADATA +12 -10
- {bumble-0.0.204.dist-info → bumble-0.0.207.dist-info}/RECORD +37 -34
- {bumble-0.0.204.dist-info → bumble-0.0.207.dist-info}/WHEEL +1 -1
- {bumble-0.0.204.dist-info → bumble-0.0.207.dist-info}/entry_points.txt +1 -0
- bumble/apps/lea_unicast/liblc3.wasm +0 -0
- {bumble-0.0.204.dist-info → bumble-0.0.207.dist-info}/LICENSE +0 -0
- {bumble-0.0.204.dist-info → bumble-0.0.207.dist-info}/top_level.txt +0 -0
bumble/audio/io.py
ADDED
|
@@ -0,0 +1,553 @@
|
|
|
1
|
+
# Copyright 2025 Google LLC
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# https://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
# -----------------------------------------------------------------------------
|
|
16
|
+
# Imports
|
|
17
|
+
# -----------------------------------------------------------------------------
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import asyncio
|
|
21
|
+
import abc
|
|
22
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
23
|
+
import dataclasses
|
|
24
|
+
import enum
|
|
25
|
+
import logging
|
|
26
|
+
import pathlib
|
|
27
|
+
from typing import (
|
|
28
|
+
AsyncGenerator,
|
|
29
|
+
BinaryIO,
|
|
30
|
+
TYPE_CHECKING,
|
|
31
|
+
)
|
|
32
|
+
import sys
|
|
33
|
+
import wave
|
|
34
|
+
|
|
35
|
+
from bumble.colors import color
|
|
36
|
+
|
|
37
|
+
if TYPE_CHECKING:
|
|
38
|
+
import sounddevice # type: ignore[import-untyped]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# -----------------------------------------------------------------------------
|
|
42
|
+
# Logging
|
|
43
|
+
# -----------------------------------------------------------------------------
|
|
44
|
+
logger = logging.getLogger(__name__)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# -----------------------------------------------------------------------------
|
|
48
|
+
# Classes
|
|
49
|
+
# -----------------------------------------------------------------------------
|
|
50
|
+
@dataclasses.dataclass
|
|
51
|
+
class PcmFormat:
|
|
52
|
+
class Endianness(enum.Enum):
|
|
53
|
+
LITTLE = 0
|
|
54
|
+
BIG = 1
|
|
55
|
+
|
|
56
|
+
class SampleType(enum.Enum):
|
|
57
|
+
FLOAT32 = 0
|
|
58
|
+
INT16 = 1
|
|
59
|
+
|
|
60
|
+
endianness: Endianness
|
|
61
|
+
sample_type: SampleType
|
|
62
|
+
sample_rate: int
|
|
63
|
+
channels: int
|
|
64
|
+
|
|
65
|
+
@classmethod
|
|
66
|
+
def from_str(cls, format_str: str) -> PcmFormat:
|
|
67
|
+
endianness = cls.Endianness.LITTLE # Others not yet supported.
|
|
68
|
+
sample_type_str, sample_rate_str, channels_str = format_str.split(',')
|
|
69
|
+
if sample_type_str == 'int16le':
|
|
70
|
+
sample_type = cls.SampleType.INT16
|
|
71
|
+
elif sample_type_str == 'float32le':
|
|
72
|
+
sample_type = cls.SampleType.FLOAT32
|
|
73
|
+
else:
|
|
74
|
+
raise ValueError(f'sample type {sample_type_str} not supported')
|
|
75
|
+
sample_rate = int(sample_rate_str)
|
|
76
|
+
channels = int(channels_str)
|
|
77
|
+
|
|
78
|
+
return cls(endianness, sample_type, sample_rate, channels)
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def bytes_per_sample(self) -> int:
|
|
82
|
+
return 2 if self.sample_type == self.SampleType.INT16 else 4
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def check_audio_output(output: str) -> bool:
|
|
86
|
+
if output == 'device' or output.startswith('device:'):
|
|
87
|
+
try:
|
|
88
|
+
import sounddevice
|
|
89
|
+
except ImportError as exc:
|
|
90
|
+
raise ValueError(
|
|
91
|
+
'audio output not available (sounddevice python module not installed)'
|
|
92
|
+
) from exc
|
|
93
|
+
except OSError as exc:
|
|
94
|
+
raise ValueError(
|
|
95
|
+
'audio output not available '
|
|
96
|
+
'(sounddevice python module failed to load: '
|
|
97
|
+
f'{exc})'
|
|
98
|
+
) from exc
|
|
99
|
+
|
|
100
|
+
if output == 'device':
|
|
101
|
+
# Default device
|
|
102
|
+
return True
|
|
103
|
+
|
|
104
|
+
# Specific device
|
|
105
|
+
device = output[7:]
|
|
106
|
+
if device == '?':
|
|
107
|
+
print(color('Audio Devices:', 'yellow'))
|
|
108
|
+
for device_info in [
|
|
109
|
+
device_info
|
|
110
|
+
for device_info in sounddevice.query_devices()
|
|
111
|
+
if device_info['max_output_channels'] > 0
|
|
112
|
+
]:
|
|
113
|
+
device_index = device_info['index']
|
|
114
|
+
is_default = (
|
|
115
|
+
color(' [default]', 'green')
|
|
116
|
+
if sounddevice.default.device[1] == device_index
|
|
117
|
+
else ''
|
|
118
|
+
)
|
|
119
|
+
print(
|
|
120
|
+
f'{color(device_index, "cyan")}: {device_info["name"]}{is_default}'
|
|
121
|
+
)
|
|
122
|
+
return False
|
|
123
|
+
|
|
124
|
+
try:
|
|
125
|
+
device_info = sounddevice.query_devices(int(device))
|
|
126
|
+
except sounddevice.PortAudioError as exc:
|
|
127
|
+
raise ValueError('No such audio device') from exc
|
|
128
|
+
|
|
129
|
+
if device_info['max_output_channels'] < 1:
|
|
130
|
+
raise ValueError(
|
|
131
|
+
f'Device {device} ({device_info["name"]}) does not have an output'
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
return True
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
async def create_audio_output(output: str) -> AudioOutput:
|
|
138
|
+
if output == 'stdout':
|
|
139
|
+
return StreamAudioOutput(sys.stdout.buffer)
|
|
140
|
+
|
|
141
|
+
if output == 'device' or output.startswith('device:'):
|
|
142
|
+
device_name = '' if output == 'device' else output[7:]
|
|
143
|
+
return SoundDeviceAudioOutput(device_name)
|
|
144
|
+
|
|
145
|
+
if output == 'ffplay':
|
|
146
|
+
return SubprocessAudioOutput(
|
|
147
|
+
command=(
|
|
148
|
+
'ffplay -probesize 32 -fflags nobuffer -analyzeduration 0 '
|
|
149
|
+
'-ar {sample_rate} '
|
|
150
|
+
'-ch_layout {channel_layout} '
|
|
151
|
+
'-f f32le pipe:0'
|
|
152
|
+
)
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
if output.startswith('file:'):
|
|
156
|
+
return FileAudioOutput(output[5:])
|
|
157
|
+
|
|
158
|
+
raise ValueError('unsupported audio output')
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class AudioOutput(abc.ABC):
|
|
162
|
+
"""Audio output to which PCM samples can be written."""
|
|
163
|
+
|
|
164
|
+
async def open(self, pcm_format: PcmFormat) -> None:
|
|
165
|
+
"""Start the output."""
|
|
166
|
+
|
|
167
|
+
@abc.abstractmethod
|
|
168
|
+
def write(self, pcm_samples: bytes) -> None:
|
|
169
|
+
"""Write PCM samples. Must not block."""
|
|
170
|
+
|
|
171
|
+
async def aclose(self) -> None:
|
|
172
|
+
"""Close the output."""
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
class ThreadedAudioOutput(AudioOutput):
|
|
176
|
+
"""Base class for AudioOutput classes that may need to call blocking functions.
|
|
177
|
+
|
|
178
|
+
The actual writing is performed in a thread, so as to ensure that calling write()
|
|
179
|
+
does not block the caller.
|
|
180
|
+
"""
|
|
181
|
+
|
|
182
|
+
def __init__(self) -> None:
|
|
183
|
+
self._thread_pool = ThreadPoolExecutor(1)
|
|
184
|
+
self._pcm_samples: asyncio.Queue[bytes] = asyncio.Queue()
|
|
185
|
+
self._write_task = asyncio.create_task(self._write_loop())
|
|
186
|
+
|
|
187
|
+
async def _write_loop(self) -> None:
|
|
188
|
+
while True:
|
|
189
|
+
pcm_samples = await self._pcm_samples.get()
|
|
190
|
+
await asyncio.get_running_loop().run_in_executor(
|
|
191
|
+
self._thread_pool, self._write, pcm_samples
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
@abc.abstractmethod
|
|
195
|
+
def _write(self, pcm_samples: bytes) -> None:
|
|
196
|
+
"""This method does the actual writing and can block."""
|
|
197
|
+
|
|
198
|
+
def write(self, pcm_samples: bytes) -> None:
|
|
199
|
+
self._pcm_samples.put_nowait(pcm_samples)
|
|
200
|
+
|
|
201
|
+
def _close(self) -> None:
|
|
202
|
+
"""This method does the actual closing and can block."""
|
|
203
|
+
|
|
204
|
+
async def aclose(self) -> None:
|
|
205
|
+
await asyncio.get_running_loop().run_in_executor(self._thread_pool, self._close)
|
|
206
|
+
self._write_task.cancel()
|
|
207
|
+
self._thread_pool.shutdown()
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
class SoundDeviceAudioOutput(ThreadedAudioOutput):
|
|
211
|
+
def __init__(self, device_name: str) -> None:
|
|
212
|
+
super().__init__()
|
|
213
|
+
self._device = int(device_name) if device_name else None
|
|
214
|
+
self._stream: sounddevice.RawOutputStream | None = None
|
|
215
|
+
|
|
216
|
+
async def open(self, pcm_format: PcmFormat) -> None:
|
|
217
|
+
import sounddevice # pylint: disable=import-error
|
|
218
|
+
|
|
219
|
+
self._stream = sounddevice.RawOutputStream(
|
|
220
|
+
samplerate=pcm_format.sample_rate,
|
|
221
|
+
device=self._device,
|
|
222
|
+
channels=pcm_format.channels,
|
|
223
|
+
dtype='float32',
|
|
224
|
+
)
|
|
225
|
+
self._stream.start()
|
|
226
|
+
|
|
227
|
+
def _write(self, pcm_samples: bytes) -> None:
|
|
228
|
+
if self._stream is None:
|
|
229
|
+
return
|
|
230
|
+
|
|
231
|
+
try:
|
|
232
|
+
self._stream.write(pcm_samples)
|
|
233
|
+
except Exception as error:
|
|
234
|
+
print(f'Sound device error: {error}')
|
|
235
|
+
raise
|
|
236
|
+
|
|
237
|
+
def _close(self):
|
|
238
|
+
self._stream.stop()
|
|
239
|
+
self._stream = None
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
class StreamAudioOutput(ThreadedAudioOutput):
|
|
243
|
+
"""AudioOutput where PCM samples are written to a stream that may block."""
|
|
244
|
+
|
|
245
|
+
def __init__(self, stream: BinaryIO) -> None:
|
|
246
|
+
super().__init__()
|
|
247
|
+
self._stream = stream
|
|
248
|
+
|
|
249
|
+
def _write(self, pcm_samples: bytes) -> None:
|
|
250
|
+
self._stream.write(pcm_samples)
|
|
251
|
+
self._stream.flush()
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
class FileAudioOutput(StreamAudioOutput):
|
|
255
|
+
"""AudioOutput where PCM samples are written to a file."""
|
|
256
|
+
|
|
257
|
+
def __init__(self, filename: str) -> None:
|
|
258
|
+
self._file = open(filename, "wb")
|
|
259
|
+
super().__init__(self._file)
|
|
260
|
+
|
|
261
|
+
async def shutdown(self):
|
|
262
|
+
self._file.close()
|
|
263
|
+
return await super().shutdown()
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
class SubprocessAudioOutput(AudioOutput):
|
|
267
|
+
"""AudioOutput where audio samples are written to a subprocess via stdin."""
|
|
268
|
+
|
|
269
|
+
def __init__(self, command: str) -> None:
|
|
270
|
+
self._command = command
|
|
271
|
+
self._subprocess: asyncio.subprocess.Process | None
|
|
272
|
+
|
|
273
|
+
async def open(self, pcm_format: PcmFormat) -> None:
|
|
274
|
+
if pcm_format.channels == 1:
|
|
275
|
+
channel_layout = 'mono'
|
|
276
|
+
elif pcm_format.channels == 2:
|
|
277
|
+
channel_layout = 'stereo'
|
|
278
|
+
else:
|
|
279
|
+
raise ValueError(f'{pcm_format.channels} channels not supported')
|
|
280
|
+
|
|
281
|
+
command = self._command.format(
|
|
282
|
+
sample_rate=pcm_format.sample_rate, channel_layout=channel_layout
|
|
283
|
+
)
|
|
284
|
+
self._subprocess = await asyncio.create_subprocess_shell(
|
|
285
|
+
command,
|
|
286
|
+
stdin=asyncio.subprocess.PIPE,
|
|
287
|
+
stdout=asyncio.subprocess.PIPE,
|
|
288
|
+
stderr=asyncio.subprocess.PIPE,
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
def write(self, pcm_samples: bytes) -> None:
|
|
292
|
+
if self._subprocess is None or self._subprocess.stdin is None:
|
|
293
|
+
return
|
|
294
|
+
|
|
295
|
+
self._subprocess.stdin.write(pcm_samples)
|
|
296
|
+
|
|
297
|
+
async def aclose(self):
|
|
298
|
+
if self._subprocess:
|
|
299
|
+
self._subprocess.terminate()
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def check_audio_input(input: str) -> bool:
|
|
303
|
+
if input == 'device' or input.startswith('device:'):
|
|
304
|
+
try:
|
|
305
|
+
import sounddevice # pylint: disable=import-error
|
|
306
|
+
except ImportError as exc:
|
|
307
|
+
raise ValueError(
|
|
308
|
+
'audio input not available (sounddevice python module not installed)'
|
|
309
|
+
) from exc
|
|
310
|
+
except OSError as exc:
|
|
311
|
+
raise ValueError(
|
|
312
|
+
'audio input not available '
|
|
313
|
+
'(sounddevice python module failed to load: '
|
|
314
|
+
f'{exc})'
|
|
315
|
+
) from exc
|
|
316
|
+
|
|
317
|
+
if input == 'device':
|
|
318
|
+
# Default device
|
|
319
|
+
return True
|
|
320
|
+
|
|
321
|
+
# Specific device
|
|
322
|
+
device = input[7:]
|
|
323
|
+
if device == '?':
|
|
324
|
+
print(color('Audio Devices:', 'yellow'))
|
|
325
|
+
for device_info in [
|
|
326
|
+
device_info
|
|
327
|
+
for device_info in sounddevice.query_devices()
|
|
328
|
+
if device_info['max_input_channels'] > 0
|
|
329
|
+
]:
|
|
330
|
+
device_index = device_info["index"]
|
|
331
|
+
is_mono = device_info['max_input_channels'] == 1
|
|
332
|
+
max_channels = color(f'[{"mono" if is_mono else "stereo"}]', 'cyan')
|
|
333
|
+
is_default = (
|
|
334
|
+
color(' [default]', 'green')
|
|
335
|
+
if sounddevice.default.device[0] == device_index
|
|
336
|
+
else ''
|
|
337
|
+
)
|
|
338
|
+
print(
|
|
339
|
+
f'{color(device_index, "cyan")}: {device_info["name"]}'
|
|
340
|
+
f' {max_channels}{is_default}'
|
|
341
|
+
)
|
|
342
|
+
return False
|
|
343
|
+
|
|
344
|
+
try:
|
|
345
|
+
device_info = sounddevice.query_devices(int(device))
|
|
346
|
+
except sounddevice.PortAudioError as exc:
|
|
347
|
+
raise ValueError('No such audio device') from exc
|
|
348
|
+
|
|
349
|
+
if device_info['max_input_channels'] < 1:
|
|
350
|
+
raise ValueError(
|
|
351
|
+
f'Device {device} ({device_info["name"]}) does not have an input'
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
return True
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
async def create_audio_input(input: str, input_format: str) -> AudioInput:
|
|
358
|
+
pcm_format: PcmFormat | None
|
|
359
|
+
if input_format == 'auto':
|
|
360
|
+
pcm_format = None
|
|
361
|
+
else:
|
|
362
|
+
pcm_format = PcmFormat.from_str(input_format)
|
|
363
|
+
|
|
364
|
+
if input == 'stdin':
|
|
365
|
+
if not pcm_format:
|
|
366
|
+
raise ValueError('input format details required for stdin')
|
|
367
|
+
return StreamAudioInput(sys.stdin.buffer, pcm_format)
|
|
368
|
+
|
|
369
|
+
if input == 'device' or input.startswith('device:'):
|
|
370
|
+
if not pcm_format:
|
|
371
|
+
raise ValueError('input format details required for device')
|
|
372
|
+
device_name = '' if input == 'device' else input[7:]
|
|
373
|
+
return SoundDeviceAudioInput(device_name, pcm_format)
|
|
374
|
+
|
|
375
|
+
# If there's no file: prefix, check if we can assume it is a file.
|
|
376
|
+
if pathlib.Path(input).is_file():
|
|
377
|
+
input = 'file:' + input
|
|
378
|
+
|
|
379
|
+
if input.startswith('file:'):
|
|
380
|
+
filename = input[5:]
|
|
381
|
+
if filename.endswith('.wav'):
|
|
382
|
+
if input_format != 'auto':
|
|
383
|
+
raise ValueError(".wav file only supported with 'auto' format")
|
|
384
|
+
return WaveAudioInput(filename)
|
|
385
|
+
|
|
386
|
+
if pcm_format is None:
|
|
387
|
+
raise ValueError('input format details required for raw PCM files')
|
|
388
|
+
return FileAudioInput(filename, pcm_format)
|
|
389
|
+
|
|
390
|
+
raise ValueError('input not supported')
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
class AudioInput(abc.ABC):
|
|
394
|
+
"""Audio input that produces PCM samples."""
|
|
395
|
+
|
|
396
|
+
@abc.abstractmethod
|
|
397
|
+
async def open(self) -> PcmFormat:
|
|
398
|
+
"""Open the input."""
|
|
399
|
+
|
|
400
|
+
@abc.abstractmethod
|
|
401
|
+
def frames(self, frame_size: int) -> AsyncGenerator[bytes]:
|
|
402
|
+
"""Generate one frame of PCM samples. Must not block."""
|
|
403
|
+
|
|
404
|
+
async def aclose(self) -> None:
|
|
405
|
+
"""Close the input."""
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
class ThreadedAudioInput(AudioInput):
|
|
409
|
+
"""Base class for AudioInput implementation where reading samples may block."""
|
|
410
|
+
|
|
411
|
+
def __init__(self) -> None:
|
|
412
|
+
self._thread_pool = ThreadPoolExecutor(1)
|
|
413
|
+
self._pcm_samples: asyncio.Queue[bytes] = asyncio.Queue()
|
|
414
|
+
|
|
415
|
+
@abc.abstractmethod
|
|
416
|
+
def _read(self, frame_size: int) -> bytes:
|
|
417
|
+
pass
|
|
418
|
+
|
|
419
|
+
@abc.abstractmethod
|
|
420
|
+
def _open(self) -> PcmFormat:
|
|
421
|
+
pass
|
|
422
|
+
|
|
423
|
+
def _close(self) -> None:
|
|
424
|
+
pass
|
|
425
|
+
|
|
426
|
+
async def open(self) -> PcmFormat:
|
|
427
|
+
return await asyncio.get_running_loop().run_in_executor(
|
|
428
|
+
self._thread_pool, self._open
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
async def frames(self, frame_size: int) -> AsyncGenerator[bytes]:
|
|
432
|
+
while pcm_sample := await asyncio.get_running_loop().run_in_executor(
|
|
433
|
+
self._thread_pool, self._read, frame_size
|
|
434
|
+
):
|
|
435
|
+
yield pcm_sample
|
|
436
|
+
|
|
437
|
+
async def aclose(self) -> None:
|
|
438
|
+
await asyncio.get_running_loop().run_in_executor(self._thread_pool, self._close)
|
|
439
|
+
self._thread_pool.shutdown()
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
class WaveAudioInput(ThreadedAudioInput):
|
|
443
|
+
"""Audio input that reads PCM samples from a .wav file."""
|
|
444
|
+
|
|
445
|
+
def __init__(self, filename: str) -> None:
|
|
446
|
+
super().__init__()
|
|
447
|
+
self._filename = filename
|
|
448
|
+
self._wav: wave.Wave_read | None = None
|
|
449
|
+
self._bytes_read = 0
|
|
450
|
+
|
|
451
|
+
def _open(self) -> PcmFormat:
|
|
452
|
+
self._wav = wave.open(self._filename, 'rb')
|
|
453
|
+
if self._wav.getsampwidth() != 2:
|
|
454
|
+
raise ValueError('sample width not supported')
|
|
455
|
+
return PcmFormat(
|
|
456
|
+
PcmFormat.Endianness.LITTLE,
|
|
457
|
+
PcmFormat.SampleType.INT16,
|
|
458
|
+
self._wav.getframerate(),
|
|
459
|
+
self._wav.getnchannels(),
|
|
460
|
+
)
|
|
461
|
+
|
|
462
|
+
def _read(self, frame_size: int) -> bytes:
|
|
463
|
+
if not self._wav:
|
|
464
|
+
return b''
|
|
465
|
+
|
|
466
|
+
pcm_samples = self._wav.readframes(frame_size)
|
|
467
|
+
if not pcm_samples and self._bytes_read:
|
|
468
|
+
# Loop around.
|
|
469
|
+
self._wav.rewind()
|
|
470
|
+
self._bytes_read = 0
|
|
471
|
+
pcm_samples = self._wav.readframes(frame_size)
|
|
472
|
+
|
|
473
|
+
self._bytes_read += len(pcm_samples)
|
|
474
|
+
return pcm_samples
|
|
475
|
+
|
|
476
|
+
def _close(self) -> None:
|
|
477
|
+
if self._wav:
|
|
478
|
+
self._wav.close()
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
class StreamAudioInput(ThreadedAudioInput):
|
|
482
|
+
"""AudioInput where samples are read from a raw PCM stream that may block."""
|
|
483
|
+
|
|
484
|
+
def __init__(self, stream: BinaryIO, pcm_format: PcmFormat) -> None:
|
|
485
|
+
super().__init__()
|
|
486
|
+
self._stream = stream
|
|
487
|
+
self._pcm_format = pcm_format
|
|
488
|
+
|
|
489
|
+
def _open(self) -> PcmFormat:
|
|
490
|
+
return self._pcm_format
|
|
491
|
+
|
|
492
|
+
def _read(self, frame_size: int) -> bytes:
|
|
493
|
+
return self._stream.read(
|
|
494
|
+
frame_size * self._pcm_format.channels * self._pcm_format.bytes_per_sample
|
|
495
|
+
)
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
class FileAudioInput(StreamAudioInput):
|
|
499
|
+
"""AudioInput where PCM samples are read from a raw PCM file."""
|
|
500
|
+
|
|
501
|
+
def __init__(self, filename: str, pcm_format: PcmFormat) -> None:
|
|
502
|
+
self._stream = open(filename, "rb")
|
|
503
|
+
super().__init__(self._stream, pcm_format)
|
|
504
|
+
|
|
505
|
+
def _close(self) -> None:
|
|
506
|
+
self._stream.close()
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
class SoundDeviceAudioInput(ThreadedAudioInput):
|
|
510
|
+
def __init__(self, device_name: str, pcm_format: PcmFormat) -> None:
|
|
511
|
+
super().__init__()
|
|
512
|
+
self._device = int(device_name) if device_name else None
|
|
513
|
+
self._pcm_format = pcm_format
|
|
514
|
+
self._stream: sounddevice.RawInputStream | None = None
|
|
515
|
+
|
|
516
|
+
def _open(self) -> PcmFormat:
|
|
517
|
+
import sounddevice # pylint: disable=import-error
|
|
518
|
+
|
|
519
|
+
self._stream = sounddevice.RawInputStream(
|
|
520
|
+
samplerate=self._pcm_format.sample_rate,
|
|
521
|
+
device=self._device,
|
|
522
|
+
channels=self._pcm_format.channels,
|
|
523
|
+
dtype='int16',
|
|
524
|
+
)
|
|
525
|
+
self._stream.start()
|
|
526
|
+
|
|
527
|
+
return PcmFormat(
|
|
528
|
+
PcmFormat.Endianness.LITTLE,
|
|
529
|
+
PcmFormat.SampleType.INT16,
|
|
530
|
+
self._pcm_format.sample_rate,
|
|
531
|
+
2,
|
|
532
|
+
)
|
|
533
|
+
|
|
534
|
+
def _read(self, frame_size: int) -> bytes:
|
|
535
|
+
if not self._stream:
|
|
536
|
+
return b''
|
|
537
|
+
pcm_buffer, overflowed = self._stream.read(frame_size)
|
|
538
|
+
if overflowed:
|
|
539
|
+
logger.warning("input overflow")
|
|
540
|
+
|
|
541
|
+
# Convert the buffer to stereo if needed
|
|
542
|
+
if self._pcm_format.channels == 1:
|
|
543
|
+
stereo_buffer = bytearray()
|
|
544
|
+
for i in range(frame_size):
|
|
545
|
+
sample = pcm_buffer[i * 2 : i * 2 + 2]
|
|
546
|
+
stereo_buffer += sample + sample
|
|
547
|
+
return stereo_buffer
|
|
548
|
+
|
|
549
|
+
return bytes(pcm_buffer)
|
|
550
|
+
|
|
551
|
+
def _close(self):
|
|
552
|
+
self._stream.stop()
|
|
553
|
+
self._stream = None
|
bumble/controller.py
CHANGED
|
@@ -154,15 +154,17 @@ class Controller:
|
|
|
154
154
|
'0000000060000000'
|
|
155
155
|
) # BR/EDR Not Supported, LE Supported (Controller)
|
|
156
156
|
self.manufacturer_name = 0xFFFF
|
|
157
|
-
self.
|
|
158
|
-
self.
|
|
159
|
-
self.
|
|
160
|
-
self.
|
|
157
|
+
self.acl_data_packet_length = 27
|
|
158
|
+
self.total_num_acl_data_packets = 64
|
|
159
|
+
self.le_acl_data_packet_length = 27
|
|
160
|
+
self.total_num_le_acl_data_packets = 64
|
|
161
|
+
self.iso_data_packet_length = 960
|
|
162
|
+
self.total_num_iso_data_packets = 64
|
|
161
163
|
self.event_mask = 0
|
|
162
164
|
self.event_mask_page_2 = 0
|
|
163
165
|
self.supported_commands = bytes.fromhex(
|
|
164
166
|
'2000800000c000000000e4000000a822000000000000040000f7ffff7f000000'
|
|
165
|
-
'
|
|
167
|
+
'30f0f9ff01008004002000000000000000000000000000000000000000000000'
|
|
166
168
|
)
|
|
167
169
|
self.le_event_mask = 0
|
|
168
170
|
self.advertising_parameters = None
|
|
@@ -1181,9 +1183,9 @@ class Controller:
|
|
|
1181
1183
|
return struct.pack(
|
|
1182
1184
|
'<BHBHH',
|
|
1183
1185
|
HCI_SUCCESS,
|
|
1184
|
-
self.
|
|
1186
|
+
self.acl_data_packet_length,
|
|
1185
1187
|
0,
|
|
1186
|
-
self.
|
|
1188
|
+
self.total_num_acl_data_packets,
|
|
1187
1189
|
0,
|
|
1188
1190
|
)
|
|
1189
1191
|
|
|
@@ -1212,8 +1214,21 @@ class Controller:
|
|
|
1212
1214
|
return struct.pack(
|
|
1213
1215
|
'<BHB',
|
|
1214
1216
|
HCI_SUCCESS,
|
|
1215
|
-
self.
|
|
1216
|
-
self.
|
|
1217
|
+
self.le_acl_data_packet_length,
|
|
1218
|
+
self.total_num_le_acl_data_packets,
|
|
1219
|
+
)
|
|
1220
|
+
|
|
1221
|
+
def on_hci_le_read_buffer_size_v2_command(self, _command):
|
|
1222
|
+
'''
|
|
1223
|
+
See Bluetooth spec Vol 4, Part E - 7.8.2 LE Read Buffer Size Command
|
|
1224
|
+
'''
|
|
1225
|
+
return struct.pack(
|
|
1226
|
+
'<BHBHB',
|
|
1227
|
+
HCI_SUCCESS,
|
|
1228
|
+
self.le_acl_data_packet_length,
|
|
1229
|
+
self.total_num_le_acl_data_packets,
|
|
1230
|
+
self.iso_data_packet_length,
|
|
1231
|
+
self.total_num_iso_data_packets,
|
|
1217
1232
|
)
|
|
1218
1233
|
|
|
1219
1234
|
def on_hci_le_read_local_supported_features_command(self, _command):
|
bumble/core.py
CHANGED
|
@@ -1501,7 +1501,10 @@ class AdvertisingData:
|
|
|
1501
1501
|
ad_data_str = f'"{ad_data.decode("utf-8")}"'
|
|
1502
1502
|
elif ad_type == AdvertisingData.COMPLETE_LOCAL_NAME:
|
|
1503
1503
|
ad_type_str = 'Complete Local Name'
|
|
1504
|
-
|
|
1504
|
+
try:
|
|
1505
|
+
ad_data_str = f'"{ad_data.decode("utf-8")}"'
|
|
1506
|
+
except UnicodeDecodeError:
|
|
1507
|
+
ad_data_str = ad_data.hex()
|
|
1505
1508
|
elif ad_type == AdvertisingData.TX_POWER_LEVEL:
|
|
1506
1509
|
ad_type_str = 'TX Power Level'
|
|
1507
1510
|
ad_data_str = str(ad_data[0])
|