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/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.hc_data_packet_length = 27
158
- self.hc_total_num_data_packets = 64
159
- self.hc_le_data_packet_length = 27
160
- self.hc_total_num_le_data_packets = 64
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
- '30f0f9ff01008004000000000000000000000000000000000000000000000000'
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.hc_data_packet_length,
1186
+ self.acl_data_packet_length,
1185
1187
  0,
1186
- self.hc_total_num_data_packets,
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.hc_le_data_packet_length,
1216
- self.hc_total_num_le_data_packets,
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
- ad_data_str = f'"{ad_data.decode("utf-8")}"'
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])