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/apps/auracast.py CHANGED
@@ -1,4 +1,4 @@
1
- # Copyright 2024 Google LLC
1
+ # Copyright 2025 Google LLC
2
2
  #
3
3
  # Licensed under the Apache License, Version 2.0 (the "License");
4
4
  # you may not use this file except in compliance with the License.
@@ -16,29 +16,50 @@
16
16
  # Imports
17
17
  # -----------------------------------------------------------------------------
18
18
  from __future__ import annotations
19
+
19
20
  import asyncio
21
+ import asyncio.subprocess
22
+ import collections
20
23
  import contextlib
21
24
  import dataclasses
25
+ import functools
22
26
  import logging
23
27
  import os
24
- from typing import cast, Any, AsyncGenerator, Coroutine, Dict, Optional, Tuple
28
+ import struct
29
+ from typing import (
30
+ cast,
31
+ Any,
32
+ AsyncGenerator,
33
+ Coroutine,
34
+ Deque,
35
+ Optional,
36
+ Tuple,
37
+ )
25
38
 
26
39
  import click
27
40
  import pyee
28
41
 
42
+ try:
43
+ import lc3 # type: ignore # pylint: disable=E0401
44
+ except ImportError as e:
45
+ raise ImportError(
46
+ "Try `python -m pip install \"git+https://github.com/google/liblc3.git\"`."
47
+ ) from e
48
+
49
+ from bumble.audio import io as audio_io
29
50
  from bumble.colors import color
30
- import bumble.company_ids
31
- import bumble.core
51
+ from bumble import company_ids
52
+ from bumble import core
53
+ from bumble import gatt
54
+ from bumble import hci
55
+ from bumble.profiles import bap
56
+ from bumble.profiles import le_audio
57
+ from bumble.profiles import pbp
58
+ from bumble.profiles import bass
32
59
  import bumble.device
33
- import bumble.gatt
34
- import bumble.hci
35
- import bumble.profiles.bap
36
- import bumble.profiles.bass
37
- import bumble.profiles.pbp
38
60
  import bumble.transport
39
61
  import bumble.utils
40
62
 
41
-
42
63
  # -----------------------------------------------------------------------------
43
64
  # Logging
44
65
  # -----------------------------------------------------------------------------
@@ -49,9 +70,34 @@ logger = logging.getLogger(__name__)
49
70
  # Constants
50
71
  # -----------------------------------------------------------------------------
51
72
  AURACAST_DEFAULT_DEVICE_NAME = 'Bumble Auracast'
52
- AURACAST_DEFAULT_DEVICE_ADDRESS = bumble.hci.Address('F0:F1:F2:F3:F4:F5')
73
+ AURACAST_DEFAULT_DEVICE_ADDRESS = hci.Address('F0:F1:F2:F3:F4:F5')
53
74
  AURACAST_DEFAULT_SYNC_TIMEOUT = 5.0
54
75
  AURACAST_DEFAULT_ATT_MTU = 256
76
+ AURACAST_DEFAULT_FRAME_DURATION = 10000
77
+ AURACAST_DEFAULT_SAMPLE_RATE = 48000
78
+ AURACAST_DEFAULT_TRANSMIT_BITRATE = 80000
79
+
80
+
81
+ # -----------------------------------------------------------------------------
82
+ # Utils
83
+ # -----------------------------------------------------------------------------
84
+ def codec_config_string(
85
+ codec_config: bap.CodecSpecificConfiguration, indent: str
86
+ ) -> str:
87
+ lines = []
88
+ if codec_config.sampling_frequency is not None:
89
+ lines.append(f'Sampling Frequency: {codec_config.sampling_frequency.hz} hz')
90
+ if codec_config.frame_duration is not None:
91
+ lines.append(f'Frame Duration: {codec_config.frame_duration.us} µs')
92
+ if codec_config.octets_per_codec_frame is not None:
93
+ lines.append(f'Frame Size: {codec_config.octets_per_codec_frame} bytes')
94
+ if codec_config.codec_frames_per_sdu is not None:
95
+ lines.append(f'Frames Per SDU: {codec_config.codec_frames_per_sdu}')
96
+ if codec_config.audio_channel_allocation is not None:
97
+ lines.append(
98
+ f'Audio Location: {codec_config.audio_channel_allocation.name}'
99
+ )
100
+ return '\n'.join(indent + line for line in lines)
55
101
 
56
102
 
57
103
  # -----------------------------------------------------------------------------
@@ -62,17 +108,12 @@ class BroadcastScanner(pyee.EventEmitter):
62
108
  class Broadcast(pyee.EventEmitter):
63
109
  name: str | None
64
110
  sync: bumble.device.PeriodicAdvertisingSync
111
+ broadcast_id: int
65
112
  rssi: int = 0
66
- public_broadcast_announcement: Optional[
67
- bumble.profiles.pbp.PublicBroadcastAnnouncement
68
- ] = None
69
- broadcast_audio_announcement: Optional[
70
- bumble.profiles.bap.BroadcastAudioAnnouncement
71
- ] = None
72
- basic_audio_announcement: Optional[
73
- bumble.profiles.bap.BasicAudioAnnouncement
74
- ] = None
75
- appearance: Optional[bumble.core.Appearance] = None
113
+ public_broadcast_announcement: Optional[pbp.PublicBroadcastAnnouncement] = None
114
+ broadcast_audio_announcement: Optional[bap.BroadcastAudioAnnouncement] = None
115
+ basic_audio_announcement: Optional[bap.BasicAudioAnnouncement] = None
116
+ appearance: Optional[core.Appearance] = None
76
117
  biginfo: Optional[bumble.device.BIGInfoAdvertisement] = None
77
118
  manufacturer_data: Optional[Tuple[str, bytes]] = None
78
119
 
@@ -86,42 +127,36 @@ class BroadcastScanner(pyee.EventEmitter):
86
127
  def update(self, advertisement: bumble.device.Advertisement) -> None:
87
128
  self.rssi = advertisement.rssi
88
129
  for service_data in advertisement.data.get_all(
89
- bumble.core.AdvertisingData.SERVICE_DATA
130
+ core.AdvertisingData.SERVICE_DATA
90
131
  ):
91
132
  assert isinstance(service_data, tuple)
92
133
  service_uuid, data = service_data
93
134
  assert isinstance(data, bytes)
94
135
 
95
- if (
96
- service_uuid
97
- == bumble.gatt.GATT_PUBLIC_BROADCAST_ANNOUNCEMENT_SERVICE
98
- ):
136
+ if service_uuid == gatt.GATT_PUBLIC_BROADCAST_ANNOUNCEMENT_SERVICE:
99
137
  self.public_broadcast_announcement = (
100
- bumble.profiles.pbp.PublicBroadcastAnnouncement.from_bytes(data)
138
+ pbp.PublicBroadcastAnnouncement.from_bytes(data)
101
139
  )
102
140
  continue
103
141
 
104
- if (
105
- service_uuid
106
- == bumble.gatt.GATT_BROADCAST_AUDIO_ANNOUNCEMENT_SERVICE
107
- ):
142
+ if service_uuid == gatt.GATT_BROADCAST_AUDIO_ANNOUNCEMENT_SERVICE:
108
143
  self.broadcast_audio_announcement = (
109
- bumble.profiles.bap.BroadcastAudioAnnouncement.from_bytes(data)
144
+ bap.BroadcastAudioAnnouncement.from_bytes(data)
110
145
  )
111
146
  continue
112
147
 
113
148
  self.appearance = advertisement.data.get( # type: ignore[assignment]
114
- bumble.core.AdvertisingData.APPEARANCE
149
+ core.AdvertisingData.APPEARANCE
115
150
  )
116
151
 
117
152
  if manufacturer_data := advertisement.data.get(
118
- bumble.core.AdvertisingData.MANUFACTURER_SPECIFIC_DATA
153
+ core.AdvertisingData.MANUFACTURER_SPECIFIC_DATA
119
154
  ):
120
155
  assert isinstance(manufacturer_data, tuple)
121
156
  company_id = cast(int, manufacturer_data[0])
122
157
  data = cast(bytes, manufacturer_data[1])
123
158
  self.manufacturer_data = (
124
- bumble.company_ids.COMPANY_IDENTIFIERS.get(
159
+ company_ids.COMPANY_IDENTIFIERS.get(
125
160
  company_id, f'0x{company_id:04X}'
126
161
  ),
127
162
  data,
@@ -157,18 +192,17 @@ class BroadcastScanner(pyee.EventEmitter):
157
192
  if self.public_broadcast_announcement:
158
193
  print(
159
194
  f' {color("Features", "cyan")}: '
160
- f'{self.public_broadcast_announcement.features}'
161
- )
162
- print(
163
- f' {color("Metadata", "cyan")}: '
164
- f'{self.public_broadcast_announcement.metadata}'
195
+ f'{self.public_broadcast_announcement.features.name}'
165
196
  )
197
+ print(f' {color("Metadata", "cyan")}:')
198
+ print(self.public_broadcast_announcement.metadata.pretty_print(' '))
166
199
 
167
200
  if self.basic_audio_announcement:
168
201
  print(color(' Audio:', 'cyan'))
169
202
  print(
170
203
  color(' Presentation Delay:', 'magenta'),
171
204
  self.basic_audio_announcement.presentation_delay,
205
+ "µs",
172
206
  )
173
207
  for subgroup in self.basic_audio_announcement.subgroups:
174
208
  print(color(' Subgroup:', 'magenta'))
@@ -185,17 +219,22 @@ class BroadcastScanner(pyee.EventEmitter):
185
219
  color(' Vendor Specific Codec ID:', 'green'),
186
220
  subgroup.codec_id.vendor_specific_codec_id,
187
221
  )
222
+ print(color(' Codec Config:', 'yellow'))
188
223
  print(
189
- color(' Codec Config:', 'yellow'),
190
- subgroup.codec_specific_configuration,
224
+ codec_config_string(
225
+ subgroup.codec_specific_configuration, ' '
226
+ ),
191
227
  )
192
- print(color(' Metadata: ', 'yellow'), subgroup.metadata)
228
+ print(color(' Metadata: ', 'yellow'))
229
+ print(subgroup.metadata.pretty_print(' '))
193
230
 
194
231
  for bis in subgroup.bis:
195
232
  print(color(f' BIS [{bis.index}]:', 'yellow'))
233
+ print(color(' Codec Config:', 'green'))
196
234
  print(
197
- color(' Codec Config:', 'green'),
198
- bis.codec_specific_configuration,
235
+ codec_config_string(
236
+ bis.codec_specific_configuration, ' '
237
+ ),
199
238
  )
200
239
 
201
240
  if self.biginfo:
@@ -232,15 +271,15 @@ class BroadcastScanner(pyee.EventEmitter):
232
271
  return
233
272
 
234
273
  for service_data in advertisement.data.get_all(
235
- bumble.core.AdvertisingData.SERVICE_DATA
274
+ core.AdvertisingData.SERVICE_DATA
236
275
  ):
237
276
  assert isinstance(service_data, tuple)
238
277
  service_uuid, data = service_data
239
278
  assert isinstance(data, bytes)
240
279
 
241
- if service_uuid == bumble.gatt.GATT_BASIC_AUDIO_ANNOUNCEMENT_SERVICE:
280
+ if service_uuid == gatt.GATT_BASIC_AUDIO_ANNOUNCEMENT_SERVICE:
242
281
  self.basic_audio_announcement = (
243
- bumble.profiles.bap.BasicAudioAnnouncement.from_bytes(data)
282
+ bap.BasicAudioAnnouncement.from_bytes(data)
244
283
  )
245
284
  break
246
285
 
@@ -262,7 +301,7 @@ class BroadcastScanner(pyee.EventEmitter):
262
301
  self.device = device
263
302
  self.filter_duplicates = filter_duplicates
264
303
  self.sync_timeout = sync_timeout
265
- self.broadcasts: Dict[bumble.hci.Address, BroadcastScanner.Broadcast] = {}
304
+ self.broadcasts = dict[hci.Address, BroadcastScanner.Broadcast]()
266
305
  device.on('advertisement', self.on_advertisement)
267
306
 
268
307
  async def start(self) -> None:
@@ -277,33 +316,44 @@ class BroadcastScanner(pyee.EventEmitter):
277
316
  def on_advertisement(self, advertisement: bumble.device.Advertisement) -> None:
278
317
  if not (
279
318
  ads := advertisement.data.get_all(
280
- bumble.core.AdvertisingData.SERVICE_DATA_16_BIT_UUID
319
+ core.AdvertisingData.SERVICE_DATA_16_BIT_UUID
281
320
  )
282
321
  ) or not (
283
- any(
284
- ad
285
- for ad in ads
286
- if isinstance(ad, tuple)
287
- and ad[0] == bumble.gatt.GATT_BROADCAST_AUDIO_ANNOUNCEMENT_SERVICE
322
+ broadcast_audio_announcement := next(
323
+ (
324
+ ad
325
+ for ad in ads
326
+ if isinstance(ad, tuple)
327
+ and ad[0] == gatt.GATT_BROADCAST_AUDIO_ANNOUNCEMENT_SERVICE
328
+ ),
329
+ None,
288
330
  )
289
331
  ):
290
332
  return
291
333
 
292
- broadcast_name = advertisement.data.get(
293
- bumble.core.AdvertisingData.BROADCAST_NAME
294
- )
334
+ broadcast_name = advertisement.data.get(core.AdvertisingData.BROADCAST_NAME)
295
335
  assert isinstance(broadcast_name, str) or broadcast_name is None
336
+ assert isinstance(broadcast_audio_announcement[1], bytes)
296
337
 
297
338
  if broadcast := self.broadcasts.get(advertisement.address):
298
339
  broadcast.update(advertisement)
299
340
  return
300
341
 
301
342
  bumble.utils.AsyncRunner.spawn(
302
- self.on_new_broadcast(broadcast_name, advertisement)
343
+ self.on_new_broadcast(
344
+ broadcast_name,
345
+ advertisement,
346
+ bap.BroadcastAudioAnnouncement.from_bytes(
347
+ broadcast_audio_announcement[1]
348
+ ).broadcast_id,
349
+ )
303
350
  )
304
351
 
305
352
  async def on_new_broadcast(
306
- self, name: str | None, advertisement: bumble.device.Advertisement
353
+ self,
354
+ name: str | None,
355
+ advertisement: bumble.device.Advertisement,
356
+ broadcast_id: int,
307
357
  ) -> None:
308
358
  periodic_advertising_sync = await self.device.create_periodic_advertising_sync(
309
359
  advertiser_address=advertisement.address,
@@ -311,7 +361,7 @@ class BroadcastScanner(pyee.EventEmitter):
311
361
  sync_timeout=self.sync_timeout,
312
362
  filter_duplicates=self.filter_duplicates,
313
363
  )
314
- broadcast = self.Broadcast(name, periodic_advertising_sync)
364
+ broadcast = self.Broadcast(name, periodic_advertising_sync, broadcast_id)
315
365
  broadcast.update(advertisement)
316
366
  self.broadcasts[advertisement.address] = broadcast
317
367
  periodic_advertising_sync.on('loss', lambda: self.on_broadcast_loss(broadcast))
@@ -323,10 +373,11 @@ class BroadcastScanner(pyee.EventEmitter):
323
373
  self.emit('broadcast_loss', broadcast)
324
374
 
325
375
 
326
- class PrintingBroadcastScanner:
376
+ class PrintingBroadcastScanner(pyee.EventEmitter):
327
377
  def __init__(
328
378
  self, device: bumble.device.Device, filter_duplicates: bool, sync_timeout: float
329
379
  ) -> None:
380
+ super().__init__()
330
381
  self.scanner = BroadcastScanner(device, filter_duplicates, sync_timeout)
331
382
  self.scanner.on('new_broadcast', self.on_new_broadcast)
332
383
  self.scanner.on('broadcast_loss', self.on_broadcast_loss)
@@ -461,27 +512,29 @@ async def run_assist(
461
512
  await peer.request_mtu(mtu)
462
513
 
463
514
  # Get the BASS service
464
- bass = await peer.discover_service_and_create_proxy(
465
- bumble.profiles.bass.BroadcastAudioScanServiceProxy
515
+ bass_client = await peer.discover_service_and_create_proxy(
516
+ bass.BroadcastAudioScanServiceProxy
466
517
  )
467
518
 
468
519
  # Check that the service was found
469
- if not bass:
520
+ if not bass_client:
470
521
  print(color('!!! Broadcast Audio Scan Service not found', 'red'))
471
522
  return
472
523
 
473
524
  # Subscribe to and read the broadcast receive state characteristics
474
- for i, broadcast_receive_state in enumerate(bass.broadcast_receive_states):
525
+ for i, broadcast_receive_state in enumerate(
526
+ bass_client.broadcast_receive_states
527
+ ):
475
528
  try:
476
529
  await broadcast_receive_state.subscribe(
477
530
  lambda value, i=i: print(
478
531
  f"{color(f'Broadcast Receive State Update [{i}]:', 'green')} {value}"
479
532
  )
480
533
  )
481
- except bumble.core.ProtocolError as error:
534
+ except core.ProtocolError as error:
482
535
  print(
483
536
  color(
484
- f'!!! Failed to subscribe to Broadcast Receive State characteristic:',
537
+ '!!! Failed to subscribe to Broadcast Receive State characteristic',
485
538
  'red',
486
539
  ),
487
540
  error,
@@ -497,7 +550,7 @@ async def run_assist(
497
550
 
498
551
  if command == 'add-source':
499
552
  # Find the requested broadcast
500
- await bass.remote_scan_started()
553
+ await bass_client.remote_scan_started()
501
554
  if broadcast_name:
502
555
  print(color('Scanning for broadcast:', 'cyan'), broadcast_name)
503
556
  else:
@@ -517,15 +570,15 @@ async def run_assist(
517
570
 
518
571
  # Add the source
519
572
  print(color('Adding source:', 'blue'), broadcast.sync.advertiser_address)
520
- await bass.add_source(
573
+ await bass_client.add_source(
521
574
  broadcast.sync.advertiser_address,
522
575
  broadcast.sync.sid,
523
576
  broadcast.broadcast_audio_announcement.broadcast_id,
524
- bumble.profiles.bass.PeriodicAdvertisingSyncParams.SYNCHRONIZE_TO_PA_PAST_AVAILABLE,
577
+ bass.PeriodicAdvertisingSyncParams.SYNCHRONIZE_TO_PA_PAST_AVAILABLE,
525
578
  0xFFFF,
526
579
  [
527
- bumble.profiles.bass.SubgroupInfo(
528
- bumble.profiles.bass.SubgroupInfo.ANY_BIS,
580
+ bass.SubgroupInfo(
581
+ bass.SubgroupInfo.ANY_BIS,
529
582
  bytes(broadcast.basic_audio_announcement.subgroups[0].metadata),
530
583
  )
531
584
  ],
@@ -535,7 +588,7 @@ async def run_assist(
535
588
  await broadcast.sync.transfer(peer.connection)
536
589
 
537
590
  # Notify the sink that we're done scanning.
538
- await bass.remote_scan_stopped()
591
+ await bass_client.remote_scan_stopped()
539
592
 
540
593
  await peer.sustain()
541
594
  return
@@ -546,7 +599,7 @@ async def run_assist(
546
599
  return
547
600
 
548
601
  # Find the requested broadcast
549
- await bass.remote_scan_started()
602
+ await bass_client.remote_scan_started()
550
603
  if broadcast_name:
551
604
  print(color('Scanning for broadcast:', 'cyan'), broadcast_name)
552
605
  else:
@@ -569,13 +622,13 @@ async def run_assist(
569
622
  color('Modifying source:', 'blue'),
570
623
  source_id,
571
624
  )
572
- await bass.modify_source(
625
+ await bass_client.modify_source(
573
626
  source_id,
574
- bumble.profiles.bass.PeriodicAdvertisingSyncParams.SYNCHRONIZE_TO_PA_PAST_NOT_AVAILABLE,
627
+ bass.PeriodicAdvertisingSyncParams.SYNCHRONIZE_TO_PA_PAST_NOT_AVAILABLE,
575
628
  0xFFFF,
576
629
  [
577
- bumble.profiles.bass.SubgroupInfo(
578
- bumble.profiles.bass.SubgroupInfo.ANY_BIS,
630
+ bass.SubgroupInfo(
631
+ bass.SubgroupInfo.ANY_BIS,
579
632
  bytes(broadcast.basic_audio_announcement.subgroups[0].metadata),
580
633
  )
581
634
  ],
@@ -590,7 +643,7 @@ async def run_assist(
590
643
 
591
644
  # Remove the source
592
645
  print(color('Removing source:', 'blue'), source_id)
593
- await bass.remove_source(source_id)
646
+ await bass_client.remove_source(source_id)
594
647
  await peer.sustain()
595
648
  return
596
649
 
@@ -610,14 +663,342 @@ async def run_pair(transport: str, address: str) -> None:
610
663
  print("+++ Paired")
611
664
 
612
665
 
666
+ async def run_receive(
667
+ transport: str,
668
+ broadcast_id: Optional[int],
669
+ output: str,
670
+ broadcast_code: str | None,
671
+ sync_timeout: float,
672
+ subgroup_index: int,
673
+ ) -> None:
674
+ # Run a pre-flight check for the output.
675
+ try:
676
+ if not audio_io.check_audio_output(output):
677
+ return
678
+ except ValueError as error:
679
+ print(error)
680
+ return
681
+
682
+ async with create_device(transport) as device:
683
+ if not device.supports_le_periodic_advertising:
684
+ print(color('Periodic advertising not supported', 'red'))
685
+ return
686
+
687
+ scanner = BroadcastScanner(device, False, sync_timeout)
688
+ scan_result: asyncio.Future[BroadcastScanner.Broadcast] = (
689
+ asyncio.get_running_loop().create_future()
690
+ )
691
+
692
+ def on_new_broadcast(broadcast: BroadcastScanner.Broadcast) -> None:
693
+ if scan_result.done():
694
+ return
695
+ if broadcast_id is None or broadcast.broadcast_id == broadcast_id:
696
+ scan_result.set_result(broadcast)
697
+
698
+ scanner.on('new_broadcast', on_new_broadcast)
699
+ await scanner.start()
700
+ print('Start scanning...')
701
+ broadcast = await scan_result
702
+ print('Advertisement found:')
703
+ broadcast.print()
704
+ basic_audio_announcement_scanned = asyncio.Event()
705
+
706
+ def on_change() -> None:
707
+ if (
708
+ broadcast.basic_audio_announcement
709
+ and not basic_audio_announcement_scanned.is_set()
710
+ ):
711
+ basic_audio_announcement_scanned.set()
712
+
713
+ broadcast.on('change', on_change)
714
+ if not broadcast.basic_audio_announcement:
715
+ print('Wait for Basic Audio Announcement...')
716
+ await basic_audio_announcement_scanned.wait()
717
+ print('Basic Audio Announcement found')
718
+ broadcast.print()
719
+ print('Stop scanning')
720
+ await scanner.stop()
721
+ print('Start sync to BIG')
722
+
723
+ assert broadcast.basic_audio_announcement
724
+ subgroup = broadcast.basic_audio_announcement.subgroups[subgroup_index]
725
+ configuration = subgroup.codec_specific_configuration
726
+ assert configuration
727
+ assert (sampling_frequency := configuration.sampling_frequency)
728
+ assert (frame_duration := configuration.frame_duration)
729
+
730
+ big_sync = await device.create_big_sync(
731
+ broadcast.sync,
732
+ bumble.device.BigSyncParameters(
733
+ big_sync_timeout=0x4000,
734
+ bis=[bis.index for bis in subgroup.bis],
735
+ broadcast_code=(
736
+ bytes.fromhex(broadcast_code) if broadcast_code else None
737
+ ),
738
+ ),
739
+ )
740
+ num_bis = len(big_sync.bis_links)
741
+ decoder = lc3.Decoder(
742
+ frame_duration_us=frame_duration.us,
743
+ sample_rate_hz=sampling_frequency.hz,
744
+ num_channels=num_bis,
745
+ )
746
+ lc3_queues: list[Deque[bytes]] = [collections.deque() for i in range(num_bis)]
747
+ packet_stats = [0, 0]
748
+
749
+ audio_output = await audio_io.create_audio_output(output)
750
+ # This try should be replaced with contextlib.aclosing() when python 3.9 is no
751
+ # longer needed.
752
+ try:
753
+ await audio_output.open(
754
+ audio_io.PcmFormat(
755
+ audio_io.PcmFormat.Endianness.LITTLE,
756
+ audio_io.PcmFormat.SampleType.FLOAT32,
757
+ sampling_frequency.hz,
758
+ num_bis,
759
+ )
760
+ )
761
+
762
+ def sink(queue: Deque[bytes], packet: hci.HCI_IsoDataPacket):
763
+ # TODO: re-assemble fragments and detect errors
764
+ queue.append(packet.iso_sdu_fragment)
765
+
766
+ while all(lc3_queues):
767
+ # This assumes SDUs contain one LC3 frame each, which may not
768
+ # be correct for all cases. TODO: revisit this assumption.
769
+ frame = b''.join([lc3_queue.popleft() for lc3_queue in lc3_queues])
770
+ if not frame:
771
+ print(color('!!! received empty frame', 'red'))
772
+ continue
773
+
774
+ packet_stats[0] += len(frame)
775
+ packet_stats[1] += 1
776
+ print(
777
+ f'\rRECEIVED: {packet_stats[0]} bytes in '
778
+ f'{packet_stats[1]} packets',
779
+ end='',
780
+ )
781
+
782
+ try:
783
+ pcm = decoder.decode(frame).tobytes()
784
+ except lc3.BaseError as error:
785
+ print(color(f'!!! LC3 decoding error: {error}'))
786
+ continue
787
+
788
+ audio_output.write(pcm)
789
+
790
+ for i, bis_link in enumerate(big_sync.bis_links):
791
+ print(f'Setup ISO for BIS {bis_link.handle}')
792
+ bis_link.sink = functools.partial(sink, lc3_queues[i])
793
+ await device.send_command(
794
+ hci.HCI_LE_Setup_ISO_Data_Path_Command(
795
+ connection_handle=bis_link.handle,
796
+ data_path_direction=hci.HCI_LE_Setup_ISO_Data_Path_Command.Direction.CONTROLLER_TO_HOST,
797
+ data_path_id=0,
798
+ codec_id=hci.CodingFormat(codec_id=hci.CodecID.TRANSPARENT),
799
+ controller_delay=0,
800
+ codec_configuration=b'',
801
+ ),
802
+ check_result=True,
803
+ )
804
+
805
+ terminated = asyncio.Event()
806
+ big_sync.on(big_sync.Event.TERMINATION, lambda _: terminated.set())
807
+ await terminated.wait()
808
+ finally:
809
+ await audio_output.aclose()
810
+
811
+
812
+ async def run_transmit(
813
+ transport: str,
814
+ broadcast_id: int,
815
+ broadcast_code: str | None,
816
+ broadcast_name: str,
817
+ bitrate: int,
818
+ manufacturer_data: tuple[int, bytes] | None,
819
+ input: str,
820
+ input_format: str,
821
+ ) -> None:
822
+ # Run a pre-flight check for the input.
823
+ try:
824
+ if not audio_io.check_audio_input(input):
825
+ return
826
+ except ValueError as error:
827
+ print(error)
828
+ return
829
+
830
+ async with create_device(transport) as device:
831
+ if not device.supports_le_periodic_advertising:
832
+ print(color('Periodic advertising not supported', 'red'))
833
+ return
834
+
835
+ basic_audio_announcement = bap.BasicAudioAnnouncement(
836
+ presentation_delay=40000,
837
+ subgroups=[
838
+ bap.BasicAudioAnnouncement.Subgroup(
839
+ codec_id=hci.CodingFormat(codec_id=hci.CodecID.LC3),
840
+ codec_specific_configuration=bap.CodecSpecificConfiguration(
841
+ sampling_frequency=bap.SamplingFrequency.FREQ_48000,
842
+ frame_duration=bap.FrameDuration.DURATION_10000_US,
843
+ octets_per_codec_frame=100,
844
+ ),
845
+ metadata=le_audio.Metadata(
846
+ [
847
+ le_audio.Metadata.Entry(
848
+ tag=le_audio.Metadata.Tag.LANGUAGE, data=b'eng'
849
+ ),
850
+ le_audio.Metadata.Entry(
851
+ tag=le_audio.Metadata.Tag.PROGRAM_INFO, data=b'Disco'
852
+ ),
853
+ ]
854
+ ),
855
+ bis=[
856
+ bap.BasicAudioAnnouncement.BIS(
857
+ index=1,
858
+ codec_specific_configuration=bap.CodecSpecificConfiguration(
859
+ audio_channel_allocation=bap.AudioLocation.FRONT_LEFT
860
+ ),
861
+ ),
862
+ bap.BasicAudioAnnouncement.BIS(
863
+ index=2,
864
+ codec_specific_configuration=bap.CodecSpecificConfiguration(
865
+ audio_channel_allocation=bap.AudioLocation.FRONT_RIGHT
866
+ ),
867
+ ),
868
+ ],
869
+ )
870
+ ],
871
+ )
872
+ broadcast_audio_announcement = bap.BroadcastAudioAnnouncement(broadcast_id)
873
+
874
+ advertising_manufacturer_data = (
875
+ b''
876
+ if manufacturer_data is None
877
+ else bytes(
878
+ core.AdvertisingData(
879
+ [
880
+ (
881
+ core.AdvertisingData.MANUFACTURER_SPECIFIC_DATA,
882
+ struct.pack('<H', manufacturer_data[0])
883
+ + manufacturer_data[1],
884
+ )
885
+ ]
886
+ )
887
+ )
888
+ )
889
+
890
+ advertising_set = await device.create_advertising_set(
891
+ advertising_parameters=bumble.device.AdvertisingParameters(
892
+ advertising_event_properties=bumble.device.AdvertisingEventProperties(
893
+ is_connectable=False
894
+ ),
895
+ primary_advertising_interval_min=100,
896
+ primary_advertising_interval_max=200,
897
+ ),
898
+ advertising_data=(
899
+ broadcast_audio_announcement.get_advertising_data()
900
+ + bytes(
901
+ core.AdvertisingData(
902
+ [(core.AdvertisingData.BROADCAST_NAME, broadcast_name.encode())]
903
+ )
904
+ )
905
+ + advertising_manufacturer_data
906
+ ),
907
+ periodic_advertising_parameters=bumble.device.PeriodicAdvertisingParameters(
908
+ periodic_advertising_interval_min=80,
909
+ periodic_advertising_interval_max=160,
910
+ ),
911
+ periodic_advertising_data=basic_audio_announcement.get_advertising_data(),
912
+ auto_restart=True,
913
+ auto_start=True,
914
+ )
915
+
916
+ print('Start Periodic Advertising')
917
+ await advertising_set.start_periodic()
918
+
919
+ audio_input = await audio_io.create_audio_input(input, input_format)
920
+ pcm_format = await audio_input.open()
921
+ # This try should be replaced with contextlib.aclosing() when python 3.9 is no
922
+ # longer needed.
923
+ try:
924
+ if pcm_format.channels != 2:
925
+ print("Only 2 channels PCM configurations are supported")
926
+ return
927
+ if pcm_format.sample_type == audio_io.PcmFormat.SampleType.INT16:
928
+ pcm_bit_depth = 16
929
+ elif pcm_format.sample_type == audio_io.PcmFormat.SampleType.FLOAT32:
930
+ pcm_bit_depth = None
931
+ else:
932
+ print("Only INT16 and FLOAT32 sample types are supported")
933
+ return
934
+
935
+ encoder = lc3.Encoder(
936
+ frame_duration_us=AURACAST_DEFAULT_FRAME_DURATION,
937
+ sample_rate_hz=AURACAST_DEFAULT_SAMPLE_RATE,
938
+ num_channels=pcm_format.channels,
939
+ input_sample_rate_hz=pcm_format.sample_rate,
940
+ )
941
+ lc3_frame_samples = encoder.get_frame_samples()
942
+ lc3_frame_size = encoder.get_frame_bytes(bitrate)
943
+ print(
944
+ f'Encoding with {lc3_frame_samples} '
945
+ f'PCM samples per {lc3_frame_size} byte frame'
946
+ )
947
+
948
+ print('Setup BIG')
949
+ big = await device.create_big(
950
+ advertising_set,
951
+ parameters=bumble.device.BigParameters(
952
+ num_bis=pcm_format.channels,
953
+ sdu_interval=AURACAST_DEFAULT_FRAME_DURATION,
954
+ max_sdu=lc3_frame_size,
955
+ max_transport_latency=65,
956
+ rtn=4,
957
+ broadcast_code=(
958
+ bytes.fromhex(broadcast_code) if broadcast_code else None
959
+ ),
960
+ ),
961
+ )
962
+
963
+ iso_queues = [
964
+ bumble.device.IsoPacketStream(big.bis_links[0], 64),
965
+ bumble.device.IsoPacketStream(big.bis_links[1], 64),
966
+ ]
967
+
968
+ def on_flow():
969
+ data_packet_queue = iso_queues[0].data_packet_queue
970
+ print(
971
+ f'\rPACKETS: pending={data_packet_queue.pending}, '
972
+ f'queued={data_packet_queue.queued}, '
973
+ f'completed={data_packet_queue.completed}',
974
+ end='',
975
+ )
976
+
977
+ iso_queues[0].data_packet_queue.on('flow', on_flow)
978
+
979
+ frame_count = 0
980
+ async for pcm_frame in audio_input.frames(lc3_frame_samples):
981
+ lc3_frame = encoder.encode(
982
+ pcm_frame, num_bytes=2 * lc3_frame_size, bit_depth=pcm_bit_depth
983
+ )
984
+
985
+ mid = len(lc3_frame) // 2
986
+ await iso_queues[0].write(lc3_frame[:mid])
987
+ await iso_queues[1].write(lc3_frame[mid:])
988
+
989
+ frame_count += 1
990
+ finally:
991
+ await audio_input.aclose()
992
+
993
+
613
994
  def run_async(async_command: Coroutine) -> None:
614
995
  try:
615
996
  asyncio.run(async_command)
616
- except bumble.core.ProtocolError as error:
997
+ except core.ProtocolError as error:
617
998
  if error.error_namespace == 'att' and error.error_code in list(
618
- bumble.profiles.bass.ApplicationError
999
+ bass.ApplicationError
619
1000
  ):
620
- message = bumble.profiles.bass.ApplicationError(error.error_code).name
1001
+ message = bass.ApplicationError(error.error_code).name
621
1002
  else:
622
1003
  message = str(error)
623
1004
 
@@ -631,9 +1012,7 @@ def run_async(async_command: Coroutine) -> None:
631
1012
  # -----------------------------------------------------------------------------
632
1013
  @click.group()
633
1014
  @click.pass_context
634
- def auracast(
635
- ctx,
636
- ):
1015
+ def auracast(ctx):
637
1016
  ctx.ensure_object(dict)
638
1017
 
639
1018
 
@@ -678,7 +1057,7 @@ def scan(ctx, filter_duplicates, sync_timeout, transport):
678
1057
  @click.argument('address')
679
1058
  @click.pass_context
680
1059
  def assist(ctx, broadcast_name, source_id, command, transport, address):
681
- """Scan for broadcasts on behalf of a audio server"""
1060
+ """Scan for broadcasts on behalf of an audio server"""
682
1061
  run_async(run_assist(broadcast_name, source_id, command, transport, address))
683
1062
 
684
1063
 
@@ -691,6 +1070,166 @@ def pair(ctx, transport, address):
691
1070
  run_async(run_pair(transport, address))
692
1071
 
693
1072
 
1073
+ @auracast.command('receive')
1074
+ @click.argument('transport')
1075
+ @click.argument(
1076
+ 'broadcast_id',
1077
+ type=int,
1078
+ required=False,
1079
+ )
1080
+ @click.option(
1081
+ '--output',
1082
+ default='device',
1083
+ help=(
1084
+ "Audio output. "
1085
+ "'device' -> use the host's default sound output device, "
1086
+ "'device:<DEVICE_ID>' -> use one of the host's sound output device "
1087
+ "(specify 'device:?' to get a list of available sound output devices), "
1088
+ "'stdout' -> send audio to stdout, "
1089
+ "'file:<filename> -> write audio to a raw float32 PCM file, "
1090
+ "'ffplay' -> pipe the audio to ffplay"
1091
+ ),
1092
+ )
1093
+ @click.option(
1094
+ '--broadcast-code',
1095
+ metavar='BROADCAST_CODE',
1096
+ type=str,
1097
+ help='Broadcast encryption code in hex format',
1098
+ )
1099
+ @click.option(
1100
+ '--sync-timeout',
1101
+ metavar='SYNC_TIMEOUT',
1102
+ type=float,
1103
+ default=AURACAST_DEFAULT_SYNC_TIMEOUT,
1104
+ help='Sync timeout (in seconds)',
1105
+ )
1106
+ @click.option(
1107
+ '--subgroup',
1108
+ metavar='SUBGROUP',
1109
+ type=int,
1110
+ default=0,
1111
+ help='Index of Subgroup',
1112
+ )
1113
+ @click.pass_context
1114
+ def receive(
1115
+ ctx,
1116
+ transport,
1117
+ broadcast_id,
1118
+ output,
1119
+ broadcast_code,
1120
+ sync_timeout,
1121
+ subgroup,
1122
+ ):
1123
+ """Receive a broadcast source"""
1124
+ run_async(
1125
+ run_receive(
1126
+ transport,
1127
+ broadcast_id,
1128
+ output,
1129
+ broadcast_code,
1130
+ sync_timeout,
1131
+ subgroup,
1132
+ )
1133
+ )
1134
+
1135
+
1136
+ @auracast.command('transmit')
1137
+ @click.argument('transport')
1138
+ @click.option(
1139
+ '--input',
1140
+ required=True,
1141
+ help=(
1142
+ "Audio input. "
1143
+ "'device' -> use the host's default sound input device, "
1144
+ "'device:<DEVICE_ID>' -> use one of the host's sound input devices "
1145
+ "(specify 'device:?' to get a list of available sound input devices), "
1146
+ "'stdin' -> receive audio from stdin as int16 PCM, "
1147
+ "'file:<filename> -> read audio from a .wav or raw int16 PCM file. "
1148
+ "(The file: prefix may be omitted if the file path does not start with "
1149
+ "the substring 'device:' or 'file:' and is not 'stdin')"
1150
+ ),
1151
+ )
1152
+ @click.option(
1153
+ '--input-format',
1154
+ metavar="FORMAT",
1155
+ default='auto',
1156
+ help=(
1157
+ "Audio input format. "
1158
+ "Use 'auto' for .wav files, or for the default setting with the devices. "
1159
+ "For other inputs, the format is specified as "
1160
+ "<sample-type>,<sample-rate>,<channels> (supported <sample-type>: 'int16le' "
1161
+ "for 16-bit signed integers with little-endian byte order or 'float32le' for "
1162
+ "32-bit floating point with little-endian byte order)"
1163
+ ),
1164
+ )
1165
+ @click.option(
1166
+ '--broadcast-id',
1167
+ metavar='BROADCAST_ID',
1168
+ type=int,
1169
+ default=123456,
1170
+ help='Broadcast ID',
1171
+ )
1172
+ @click.option(
1173
+ '--broadcast-code',
1174
+ metavar='BROADCAST_CODE',
1175
+ help='Broadcast encryption code in hex format',
1176
+ )
1177
+ @click.option(
1178
+ '--broadcast-name',
1179
+ metavar='BROADCAST_NAME',
1180
+ default='Bumble Auracast',
1181
+ help='Broadcast name',
1182
+ )
1183
+ @click.option(
1184
+ '--bitrate',
1185
+ type=int,
1186
+ default=AURACAST_DEFAULT_TRANSMIT_BITRATE,
1187
+ help='Bitrate, per channel, in bps',
1188
+ )
1189
+ @click.option(
1190
+ '--manufacturer-data',
1191
+ metavar='VENDOR-ID:DATA-HEX',
1192
+ help='Manufacturer data (specify as <vendor-id>:<data-hex>)',
1193
+ )
1194
+ @click.pass_context
1195
+ def transmit(
1196
+ ctx,
1197
+ transport,
1198
+ broadcast_id,
1199
+ broadcast_code,
1200
+ manufacturer_data,
1201
+ broadcast_name,
1202
+ bitrate,
1203
+ input,
1204
+ input_format,
1205
+ ):
1206
+ """Transmit a broadcast source"""
1207
+ if manufacturer_data:
1208
+ vendor_id_str, data_hex = manufacturer_data.split(':')
1209
+ vendor_id = int(vendor_id_str)
1210
+ data = bytes.fromhex(data_hex)
1211
+ manufacturer_data_tuple = (vendor_id, data)
1212
+ else:
1213
+ manufacturer_data_tuple = None
1214
+
1215
+ if (input == 'device' or input.startswith('device:')) and input_format == 'auto':
1216
+ # Use a default format for device inputs
1217
+ input_format = 'int16le,48000,1'
1218
+
1219
+ run_async(
1220
+ run_transmit(
1221
+ transport=transport,
1222
+ broadcast_id=broadcast_id,
1223
+ broadcast_code=broadcast_code,
1224
+ broadcast_name=broadcast_name,
1225
+ bitrate=bitrate,
1226
+ manufacturer_data=manufacturer_data_tuple,
1227
+ input=input,
1228
+ input_format=input_format,
1229
+ )
1230
+ )
1231
+
1232
+
694
1233
  def main():
695
1234
  logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'INFO').upper())
696
1235
  auracast()