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.
@@ -29,7 +29,9 @@ from bumble.gatt import Service
29
29
  from bumble.profiles.device_information_service import DeviceInformationServiceProxy
30
30
  from bumble.profiles.battery_service import BatteryServiceProxy
31
31
  from bumble.profiles.gap import GenericAccessServiceProxy
32
+ from bumble.profiles.pacs import PublishedAudioCapabilitiesServiceProxy
32
33
  from bumble.profiles.tmap import TelephonyAndMediaAudioServiceProxy
34
+ from bumble.profiles.vcs import VolumeControlServiceProxy
33
35
  from bumble.transport import open_transport_or_link
34
36
 
35
37
 
@@ -126,14 +128,52 @@ async def show_tmas(
126
128
  print(color('### Telephony And Media Audio Service', 'yellow'))
127
129
 
128
130
  if tmas.role:
129
- print(
130
- color(' Role:', 'green'),
131
- await tmas.role.read_value(),
132
- )
131
+ role = await tmas.role.read_value()
132
+ print(color(' Role:', 'green'), role)
133
+
134
+ print()
135
+
136
+
137
+ # -----------------------------------------------------------------------------
138
+ async def show_pacs(pacs: PublishedAudioCapabilitiesServiceProxy) -> None:
139
+ print(color('### Published Audio Capabilities Service', 'yellow'))
140
+
141
+ contexts = await pacs.available_audio_contexts.read_value()
142
+ print(color(' Available Audio Contexts:', 'green'), contexts)
143
+
144
+ contexts = await pacs.supported_audio_contexts.read_value()
145
+ print(color(' Supported Audio Contexts:', 'green'), contexts)
146
+
147
+ if pacs.sink_pac:
148
+ pac = await pacs.sink_pac.read_value()
149
+ print(color(' Sink PAC: ', 'green'), pac)
150
+
151
+ if pacs.sink_audio_locations:
152
+ audio_locations = await pacs.sink_audio_locations.read_value()
153
+ print(color(' Sink Audio Locations: ', 'green'), audio_locations)
154
+
155
+ if pacs.source_pac:
156
+ pac = await pacs.source_pac.read_value()
157
+ print(color(' Source PAC: ', 'green'), pac)
158
+
159
+ if pacs.source_audio_locations:
160
+ audio_locations = await pacs.source_audio_locations.read_value()
161
+ print(color(' Source Audio Locations: ', 'green'), audio_locations)
133
162
 
134
163
  print()
135
164
 
136
165
 
166
+ # -----------------------------------------------------------------------------
167
+ async def show_vcs(vcs: VolumeControlServiceProxy) -> None:
168
+ print(color('### Volume Control Service', 'yellow'))
169
+
170
+ volume_state = await vcs.volume_state.read_value()
171
+ print(color(' Volume State:', 'green'), volume_state)
172
+
173
+ volume_flags = await vcs.volume_flags.read_value()
174
+ print(color(' Volume Flags:', 'green'), volume_flags)
175
+
176
+
137
177
  # -----------------------------------------------------------------------------
138
178
  async def show_device_info(peer, done: Optional[asyncio.Future]) -> None:
139
179
  try:
@@ -161,6 +201,12 @@ async def show_device_info(peer, done: Optional[asyncio.Future]) -> None:
161
201
  if tmas := peer.create_service_proxy(TelephonyAndMediaAudioServiceProxy):
162
202
  await try_show(show_tmas, tmas)
163
203
 
204
+ if pacs := peer.create_service_proxy(PublishedAudioCapabilitiesServiceProxy):
205
+ await try_show(show_pacs, pacs)
206
+
207
+ if vcs := peer.create_service_proxy(VolumeControlServiceProxy):
208
+ await try_show(show_vcs, vcs)
209
+
164
210
  if done is not None:
165
211
  done.set_result(None)
166
212
  except asyncio.CancelledError:
@@ -16,23 +16,22 @@
16
16
  # Imports
17
17
  # -----------------------------------------------------------------------------
18
18
  from __future__ import annotations
19
+
19
20
  import asyncio
20
21
  import datetime
21
- import enum
22
22
  import functools
23
23
  from importlib import resources
24
24
  import json
25
25
  import os
26
26
  import logging
27
27
  import pathlib
28
- from typing import Optional, List, cast
29
28
  import weakref
30
- import struct
29
+ import wave
31
30
 
32
- import ctypes
33
- import wasmtime
34
- import wasmtime.loader
35
- import liblc3 # type: ignore
31
+ try:
32
+ import lc3 # type: ignore # pylint: disable=E0401
33
+ except ImportError as e:
34
+ raise ImportError("Try `python -m pip install \".[lc3]\"`.") from e
36
35
 
37
36
  import click
38
37
  import aiohttp.web
@@ -40,11 +39,12 @@ import aiohttp.web
40
39
  import bumble
41
40
  from bumble.core import AdvertisingData
42
41
  from bumble.colors import color
43
- from bumble.device import Device, DeviceConfiguration, AdvertisingParameters
42
+ from bumble.device import Device, DeviceConfiguration, AdvertisingParameters, CisLink
44
43
  from bumble.transport import open_transport
45
44
  from bumble.profiles import ascs, bap, pacs
46
45
  from bumble.hci import Address, CodecID, CodingFormat, HCI_IsoDataPacket
47
46
 
47
+
48
48
  # -----------------------------------------------------------------------------
49
49
  # Logging
50
50
  # -----------------------------------------------------------------------------
@@ -54,6 +54,7 @@ logger = logging.getLogger(__name__)
54
54
  # Constants
55
55
  # -----------------------------------------------------------------------------
56
56
  DEFAULT_UI_PORT = 7654
57
+ DEFAULT_PCM_BYTES_PER_SAMPLE = 2
57
58
 
58
59
 
59
60
  def _sink_pac_record() -> pacs.PacRecord:
@@ -100,153 +101,8 @@ def _source_pac_record() -> pacs.PacRecord:
100
101
  )
101
102
 
102
103
 
103
- # -----------------------------------------------------------------------------
104
- # WASM - liblc3
105
- # -----------------------------------------------------------------------------
106
- store = wasmtime.loader.store
107
- _memory = cast(wasmtime.Memory, liblc3.memory)
108
- STACK_POINTER = _memory.data_len(store)
109
- _memory.grow(store, 1)
110
- # Mapping wasmtime memory to linear address
111
- memory = (ctypes.c_ubyte * _memory.data_len(store)).from_address(
112
- ctypes.addressof(_memory.data_ptr(store).contents) # type: ignore
113
- )
114
-
115
-
116
- class Liblc3PcmFormat(enum.IntEnum):
117
- S16 = 0
118
- S24 = 1
119
- S24_3LE = 2
120
- FLOAT = 3
121
-
122
-
123
- MAX_DECODER_SIZE = liblc3.lc3_decoder_size(10000, 48000)
124
- MAX_ENCODER_SIZE = liblc3.lc3_encoder_size(10000, 48000)
125
-
126
- DECODER_STACK_POINTER = STACK_POINTER
127
- ENCODER_STACK_POINTER = DECODER_STACK_POINTER + MAX_DECODER_SIZE * 2
128
- DECODE_BUFFER_STACK_POINTER = ENCODER_STACK_POINTER + MAX_ENCODER_SIZE * 2
129
- ENCODE_BUFFER_STACK_POINTER = DECODE_BUFFER_STACK_POINTER + 8192
130
- DEFAULT_PCM_SAMPLE_RATE = 48000
131
- DEFAULT_PCM_FORMAT = Liblc3PcmFormat.S16
132
- DEFAULT_PCM_BYTES_PER_SAMPLE = 2
133
-
134
-
135
- encoders: List[int] = []
136
- decoders: List[int] = []
137
-
138
-
139
- def setup_encoders(
140
- sample_rate_hz: int, frame_duration_us: int, num_channels: int
141
- ) -> None:
142
- logger.info(
143
- f"setup_encoders {sample_rate_hz}Hz {frame_duration_us}us {num_channels}channels"
144
- )
145
- encoders[:num_channels] = [
146
- liblc3.lc3_setup_encoder(
147
- frame_duration_us,
148
- sample_rate_hz,
149
- DEFAULT_PCM_SAMPLE_RATE, # Input sample rate
150
- ENCODER_STACK_POINTER + MAX_ENCODER_SIZE * i,
151
- )
152
- for i in range(num_channels)
153
- ]
154
-
155
-
156
- def setup_decoders(
157
- sample_rate_hz: int, frame_duration_us: int, num_channels: int
158
- ) -> None:
159
- logger.info(
160
- f"setup_decoders {sample_rate_hz}Hz {frame_duration_us}us {num_channels}channels"
161
- )
162
- decoders[:num_channels] = [
163
- liblc3.lc3_setup_decoder(
164
- frame_duration_us,
165
- sample_rate_hz,
166
- DEFAULT_PCM_SAMPLE_RATE, # Output sample rate
167
- DECODER_STACK_POINTER + MAX_DECODER_SIZE * i,
168
- )
169
- for i in range(num_channels)
170
- ]
171
-
172
-
173
- def decode(
174
- frame_duration_us: int,
175
- num_channels: int,
176
- input_bytes: bytes,
177
- ) -> bytes:
178
- if not input_bytes:
179
- return b''
180
-
181
- input_buffer_offset = DECODE_BUFFER_STACK_POINTER
182
- input_buffer_size = len(input_bytes)
183
- input_bytes_per_frame = input_buffer_size // num_channels
184
-
185
- # Copy into wasm
186
- memory[input_buffer_offset : input_buffer_offset + input_buffer_size] = input_bytes # type: ignore
187
-
188
- output_buffer_offset = input_buffer_offset + input_buffer_size
189
- output_buffer_size = (
190
- liblc3.lc3_frame_samples(frame_duration_us, DEFAULT_PCM_SAMPLE_RATE)
191
- * DEFAULT_PCM_BYTES_PER_SAMPLE
192
- * num_channels
193
- )
194
-
195
- for i in range(num_channels):
196
- res = liblc3.lc3_decode(
197
- decoders[i],
198
- input_buffer_offset + input_bytes_per_frame * i,
199
- input_bytes_per_frame,
200
- DEFAULT_PCM_FORMAT,
201
- output_buffer_offset + i * DEFAULT_PCM_BYTES_PER_SAMPLE,
202
- num_channels, # Stride
203
- )
204
-
205
- if res != 0:
206
- logging.error(f"Parsing failed, res={res}")
207
-
208
- # Extract decoded data from the output buffer
209
- return bytes(
210
- memory[output_buffer_offset : output_buffer_offset + output_buffer_size]
211
- )
212
-
213
-
214
- def encode(
215
- sdu_length: int,
216
- num_channels: int,
217
- stride: int,
218
- input_bytes: bytes,
219
- ) -> bytes:
220
- if not input_bytes:
221
- return b''
222
-
223
- input_buffer_offset = ENCODE_BUFFER_STACK_POINTER
224
- input_buffer_size = len(input_bytes)
225
-
226
- # Copy into wasm
227
- memory[input_buffer_offset : input_buffer_offset + input_buffer_size] = input_bytes # type: ignore
228
-
229
- output_buffer_offset = input_buffer_offset + input_buffer_size
230
- output_buffer_size = sdu_length
231
- output_frame_size = output_buffer_size // num_channels
232
-
233
- for i in range(num_channels):
234
- res = liblc3.lc3_encode(
235
- encoders[i],
236
- DEFAULT_PCM_FORMAT,
237
- input_buffer_offset + DEFAULT_PCM_BYTES_PER_SAMPLE * i,
238
- stride,
239
- output_frame_size,
240
- output_buffer_offset + output_frame_size * i,
241
- )
242
-
243
- if res != 0:
244
- logging.error(f"Parsing failed, res={res}")
245
-
246
- # Extract decoded data from the output buffer
247
- return bytes(
248
- memory[output_buffer_offset : output_buffer_offset + output_buffer_size]
249
- )
104
+ decoder: lc3.Decoder | None = None
105
+ encoding_config: bap.CodecSpecificConfiguration | None = None
250
106
 
251
107
 
252
108
  async def lc3_source_task(
@@ -254,44 +110,49 @@ async def lc3_source_task(
254
110
  sdu_length: int,
255
111
  frame_duration_us: int,
256
112
  device: Device,
257
- cis_handle: int,
113
+ cis_link: CisLink,
258
114
  ) -> None:
259
- with open(filename, 'rb') as f:
260
- header = f.read(44)
261
- assert header[8:12] == b'WAVE'
262
-
263
- pcm_num_channel, pcm_sample_rate, _byte_rate, _block_align, bits_per_sample = (
264
- struct.unpack("<HIIHH", header[22:36])
265
- )
266
- assert pcm_sample_rate == DEFAULT_PCM_SAMPLE_RATE
267
- assert bits_per_sample == DEFAULT_PCM_BYTES_PER_SAMPLE * 8
115
+ logger.info(
116
+ "lc3_source_task filename=%s, sdu_length=%d, frame_duration=%.1f",
117
+ filename,
118
+ sdu_length,
119
+ frame_duration_us / 1000,
120
+ )
121
+ with wave.open(filename, 'rb') as wav:
122
+ bits_per_sample = wav.getsampwidth() * 8
268
123
 
269
- frame_bytes = (
270
- liblc3.lc3_frame_samples(frame_duration_us, DEFAULT_PCM_SAMPLE_RATE)
271
- * DEFAULT_PCM_BYTES_PER_SAMPLE
272
- )
273
- packet_sequence_number = 0
124
+ encoder: lc3.Encoder | None = None
274
125
 
275
126
  while True:
276
127
  next_round = datetime.datetime.now() + datetime.timedelta(
277
128
  microseconds=frame_duration_us
278
129
  )
279
- pcm_data = f.read(frame_bytes)
280
- sdu = encode(sdu_length, pcm_num_channel, pcm_num_channel, pcm_data)
281
-
282
- iso_packet = HCI_IsoDataPacket(
283
- connection_handle=cis_handle,
284
- data_total_length=sdu_length + 4,
285
- packet_sequence_number=packet_sequence_number,
286
- pb_flag=0b10,
287
- packet_status_flag=0,
288
- iso_sdu_length=sdu_length,
289
- iso_sdu_fragment=sdu,
290
- )
291
- device.host.send_hci_packet(iso_packet)
292
- packet_sequence_number += 1
130
+ if not encoder:
131
+ if (
132
+ encoding_config
133
+ and (frame_duration := encoding_config.frame_duration)
134
+ and (sampling_frequency := encoding_config.sampling_frequency)
135
+ and (
136
+ audio_channel_allocation := encoding_config.audio_channel_allocation
137
+ )
138
+ ):
139
+ logger.info("Use %s", encoding_config)
140
+ encoder = lc3.Encoder(
141
+ frame_duration_us=frame_duration.us,
142
+ sample_rate_hz=sampling_frequency.hz,
143
+ num_channels=audio_channel_allocation.channel_count,
144
+ input_sample_rate_hz=wav.getframerate(),
145
+ )
146
+ else:
147
+ sdu = encoder.encode(
148
+ pcm=wav.readframes(encoder.get_frame_samples()),
149
+ num_bytes=sdu_length,
150
+ bit_depth=bits_per_sample,
151
+ )
152
+ cis_link.write(sdu)
153
+
293
154
  sleep_time = next_round - datetime.datetime.now()
294
- await asyncio.sleep(sleep_time.total_seconds())
155
+ await asyncio.sleep(sleep_time.total_seconds() * 0.9)
295
156
 
296
157
 
297
158
  # -----------------------------------------------------------------------------
@@ -410,7 +271,7 @@ class Speaker:
410
271
 
411
272
  def __init__(
412
273
  self,
413
- device_config_path: Optional[str],
274
+ device_config_path: str | None,
414
275
  ui_port: int,
415
276
  transport: str,
416
277
  lc3_input_file_path: str,
@@ -437,6 +298,7 @@ class Speaker:
437
298
  advertising_interval_min=25,
438
299
  advertising_interval_max=25,
439
300
  address=Address('F1:F2:F3:F4:F5:F6'),
301
+ identity_address_type=Address.RANDOM_DEVICE_ADDRESS,
440
302
  )
441
303
 
442
304
  device_config.le_enabled = True
@@ -490,12 +352,12 @@ class Speaker:
490
352
  not isinstance(codec_config, bap.CodecSpecificConfiguration)
491
353
  or codec_config.frame_duration is None
492
354
  or codec_config.audio_channel_allocation is None
355
+ or decoder is None
356
+ or not pdu.iso_sdu_fragment
493
357
  ):
494
358
  return
495
- pcm = decode(
496
- codec_config.frame_duration.us,
497
- codec_config.audio_channel_allocation.channel_count,
498
- pdu.iso_sdu_fragment,
359
+ pcm = decoder.decode(
360
+ pdu.iso_sdu_fragment, bit_depth=DEFAULT_PCM_BYTES_PER_SAMPLE * 8
499
361
  )
500
362
  self.device.abort_on('disconnection', self.ui_server.send_audio(pcm))
501
363
 
@@ -521,7 +383,7 @@ class Speaker:
521
383
  ),
522
384
  frame_duration_us=codec_config.frame_duration.us,
523
385
  device=self.device,
524
- cis_handle=ase.cis_link.handle,
386
+ cis_link=ase.cis_link,
525
387
  ),
526
388
  )
527
389
  else:
@@ -537,16 +399,14 @@ class Speaker:
537
399
  ):
538
400
  return
539
401
  if ase.role == ascs.AudioRole.SOURCE:
540
- setup_encoders(
541
- codec_config.sampling_frequency.hz,
542
- codec_config.frame_duration.us,
543
- codec_config.audio_channel_allocation.channel_count,
544
- )
402
+ global encoding_config
403
+ encoding_config = codec_config
545
404
  else:
546
- setup_decoders(
547
- codec_config.sampling_frequency.hz,
548
- codec_config.frame_duration.us,
549
- codec_config.audio_channel_allocation.channel_count,
405
+ global decoder
406
+ decoder = lc3.Decoder(
407
+ frame_duration_us=codec_config.frame_duration.us,
408
+ sample_rate_hz=codec_config.sampling_frequency.hz,
409
+ num_channels=codec_config.audio_channel_allocation.channel_count,
550
410
  )
551
411
 
552
412
  for ase in ascs_service.ase_state_machines.values():
@@ -585,7 +445,7 @@ def speaker(ui_port: int, device_config: str, transport: str, lc3_file: str) ->
585
445
 
586
446
  # -----------------------------------------------------------------------------
587
447
  def main():
588
- logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'WARNING').upper())
448
+ logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'INFO').upper())
589
449
  speaker()
590
450
 
591
451
 
@@ -0,0 +1,17 @@
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
+ # -----------------------------------------------------------------------------