bumble 0.0.220__py3-none-any.whl → 0.0.221__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.
Files changed (102) hide show
  1. bumble/_version.py +2 -2
  2. bumble/a2dp.py +5 -5
  3. bumble/apps/auracast.py +746 -473
  4. bumble/apps/bench.py +4 -5
  5. bumble/apps/console.py +5 -10
  6. bumble/apps/controller_info.py +12 -7
  7. bumble/apps/controller_loopback.py +1 -2
  8. bumble/apps/device_info.py +2 -3
  9. bumble/apps/gatt_dump.py +0 -1
  10. bumble/apps/lea_unicast/app.py +1 -1
  11. bumble/apps/pair.py +49 -46
  12. bumble/apps/pandora_server.py +2 -2
  13. bumble/apps/player/player.py +10 -12
  14. bumble/apps/rfcomm_bridge.py +10 -11
  15. bumble/apps/scan.py +1 -3
  16. bumble/apps/speaker/speaker.py +3 -4
  17. bumble/at.py +4 -5
  18. bumble/att.py +91 -25
  19. bumble/audio/io.py +5 -3
  20. bumble/avc.py +1 -2
  21. bumble/avctp.py +2 -3
  22. bumble/avdtp.py +53 -57
  23. bumble/avrcp.py +25 -27
  24. bumble/codecs.py +15 -15
  25. bumble/colors.py +7 -8
  26. bumble/controller.py +663 -391
  27. bumble/core.py +41 -49
  28. bumble/crypto/__init__.py +2 -1
  29. bumble/crypto/builtin.py +2 -8
  30. bumble/data_types.py +2 -1
  31. bumble/decoder.py +2 -3
  32. bumble/device.py +171 -142
  33. bumble/drivers/__init__.py +3 -2
  34. bumble/drivers/intel.py +6 -8
  35. bumble/drivers/rtk.py +1 -1
  36. bumble/gatt.py +9 -9
  37. bumble/gatt_adapters.py +6 -6
  38. bumble/gatt_client.py +110 -60
  39. bumble/gatt_server.py +209 -139
  40. bumble/hci.py +87 -74
  41. bumble/helpers.py +5 -5
  42. bumble/hfp.py +27 -26
  43. bumble/hid.py +9 -9
  44. bumble/host.py +44 -50
  45. bumble/keys.py +17 -17
  46. bumble/l2cap.py +1015 -218
  47. bumble/link.py +26 -159
  48. bumble/ll.py +200 -0
  49. bumble/pairing.py +14 -15
  50. bumble/pandora/__init__.py +2 -2
  51. bumble/pandora/device.py +6 -4
  52. bumble/pandora/host.py +19 -10
  53. bumble/pandora/l2cap.py +8 -9
  54. bumble/pandora/security.py +18 -16
  55. bumble/pandora/utils.py +4 -4
  56. bumble/profiles/aics.py +6 -8
  57. bumble/profiles/ams.py +3 -5
  58. bumble/profiles/ancs.py +11 -11
  59. bumble/profiles/ascs.py +5 -5
  60. bumble/profiles/asha.py +10 -9
  61. bumble/profiles/bass.py +9 -3
  62. bumble/profiles/battery_service.py +1 -2
  63. bumble/profiles/csip.py +9 -10
  64. bumble/profiles/device_information_service.py +16 -17
  65. bumble/profiles/gap.py +3 -4
  66. bumble/profiles/gatt_service.py +0 -1
  67. bumble/profiles/gmap.py +12 -13
  68. bumble/profiles/hap.py +3 -3
  69. bumble/profiles/heart_rate_service.py +7 -8
  70. bumble/profiles/le_audio.py +1 -1
  71. bumble/profiles/mcp.py +28 -28
  72. bumble/profiles/pacs.py +13 -17
  73. bumble/profiles/pbp.py +16 -0
  74. bumble/profiles/vcs.py +2 -2
  75. bumble/profiles/vocs.py +6 -9
  76. bumble/rfcomm.py +19 -18
  77. bumble/sdp.py +12 -11
  78. bumble/smp.py +20 -30
  79. bumble/snoop.py +2 -1
  80. bumble/tools/generate_company_id_list.py +1 -1
  81. bumble/tools/intel_util.py +2 -2
  82. bumble/tools/rtk_fw_download.py +1 -1
  83. bumble/tools/rtk_util.py +1 -1
  84. bumble/transport/__init__.py +1 -2
  85. bumble/transport/android_emulator.py +2 -3
  86. bumble/transport/android_netsim.py +49 -40
  87. bumble/transport/common.py +9 -9
  88. bumble/transport/file.py +1 -2
  89. bumble/transport/hci_socket.py +2 -3
  90. bumble/transport/pty.py +3 -5
  91. bumble/transport/pyusb.py +8 -5
  92. bumble/transport/serial.py +1 -2
  93. bumble/transport/vhci.py +1 -2
  94. bumble/transport/ws_server.py +2 -3
  95. bumble/utils.py +22 -9
  96. bumble/vendor/android/hci.py +4 -2
  97. {bumble-0.0.220.dist-info → bumble-0.0.221.dist-info}/METADATA +3 -2
  98. {bumble-0.0.220.dist-info → bumble-0.0.221.dist-info}/RECORD +102 -101
  99. {bumble-0.0.220.dist-info → bumble-0.0.221.dist-info}/WHEEL +0 -0
  100. {bumble-0.0.220.dist-info → bumble-0.0.221.dist-info}/entry_points.txt +0 -0
  101. {bumble-0.0.220.dist-info → bumble-0.0.221.dist-info}/licenses/LICENSE +0 -0
  102. {bumble-0.0.220.dist-info → bumble-0.0.221.dist-info}/top_level.txt +0 -0
bumble/apps/auracast.py CHANGED
@@ -23,10 +23,14 @@ import contextlib
23
23
  import dataclasses
24
24
  import functools
25
25
  import logging
26
- import struct
27
- from typing import Any, AsyncGenerator, Coroutine, Optional
26
+ import secrets
27
+ from collections.abc import AsyncGenerator, Awaitable, Callable, Iterable, Sequence
28
+ from typing import (
29
+ Any,
30
+ )
28
31
 
29
32
  import click
33
+ import tomli
30
34
 
31
35
  try:
32
36
  import lc3 # type: ignore # pylint: disable=E0401
@@ -58,8 +62,11 @@ AURACAST_DEFAULT_DEVICE_ADDRESS = hci.Address('F0:F1:F2:F3:F4:F5')
58
62
  AURACAST_DEFAULT_SYNC_TIMEOUT = 5.0
59
63
  AURACAST_DEFAULT_ATT_MTU = 256
60
64
  AURACAST_DEFAULT_FRAME_DURATION = 10000
61
- AURACAST_DEFAULT_SAMPLE_RATE = 48000
62
65
  AURACAST_DEFAULT_TRANSMIT_BITRATE = 80000
66
+ AURACAST_DEFAULT_BROADCAST_ID = 123456
67
+ AURACAST_DEFAULT_BROADCAST_NAME = 'Bumble Auracast'
68
+ AURACAST_DEFAULT_LANGUAGE = 'en'
69
+ AURACAST_DEFAULT_PROGRAM_INFO = 'Disco'
63
70
 
64
71
 
65
72
  # -----------------------------------------------------------------------------
@@ -103,6 +110,95 @@ def broadcast_code_bytes(broadcast_code: str) -> bytes:
103
110
  return broadcast_code_utf8 + padding
104
111
 
105
112
 
113
+ def parse_broadcast_list(filename: str) -> Sequence[Broadcast]:
114
+ broadcasts: list[Broadcast] = []
115
+
116
+ with open(filename, "rb") as config_file:
117
+ config = tomli.load(config_file)
118
+ for broadcast in config.get("broadcasts", []):
119
+ sources = []
120
+ for source in broadcast.get("sources", []):
121
+ sources.append(
122
+ BroadcastSource(
123
+ input=source["input"],
124
+ input_format=source.get("format", "auto"),
125
+ bitrate=source.get(
126
+ "bitrate", AURACAST_DEFAULT_TRANSMIT_BITRATE
127
+ ),
128
+ )
129
+ )
130
+
131
+ manufacturer_data = broadcast.get("manufacturer_data")
132
+ if manufacturer_data is not None:
133
+ manufacturer_data = (
134
+ manufacturer_data.get("company_id"),
135
+ bytes.fromhex(manufacturer_data["data"]),
136
+ )
137
+ broadcasts.append(
138
+ Broadcast(
139
+ sources=sources,
140
+ public=broadcast.get("public", True),
141
+ broadcast_id=broadcast.get("id", AURACAST_DEFAULT_BROADCAST_ID),
142
+ broadcast_name=broadcast["name"],
143
+ broadcast_code=broadcast.get("code"),
144
+ manufacturer_data=broadcast.get("manufacturer_data"),
145
+ language=broadcast.get("language"),
146
+ program_info=broadcast.get("program_info"),
147
+ )
148
+ )
149
+
150
+ return broadcasts
151
+
152
+
153
+ def assign_broadcast_ids(broadcasts: Sequence[Broadcast]) -> None:
154
+ broadcast_ids = set()
155
+ for broadcast in broadcasts:
156
+ if broadcast.broadcast_id:
157
+ if broadcast.broadcast_id in broadcast_ids:
158
+ raise ValueError(f'duplicate broadcast ID {broadcast.broadcast_id}')
159
+ broadcast_ids.add(broadcast.broadcast_id)
160
+ else:
161
+ while True:
162
+ broadcast.broadcast_id = 1 + secrets.randbelow(0xFFFFFF)
163
+ if broadcast.broadcast_id not in broadcast_ids:
164
+ broadcast_ids.add(broadcast.broadcast_id)
165
+ break
166
+
167
+
168
+ @dataclasses.dataclass
169
+ class Broadcast:
170
+ sources: list[BroadcastSource]
171
+ public: bool
172
+ broadcast_id: int # 0 means unassigned
173
+ broadcast_name: str
174
+ broadcast_code: str | None
175
+ manufacturer_data: tuple[int, bytes] | None = None
176
+ language: str | None = None
177
+ program_info: str | None = None
178
+ audio_sources: list[AudioSource] = dataclasses.field(default_factory=list)
179
+ iso_queues: list[bumble.device.IsoPacketStream] = dataclasses.field(
180
+ default_factory=list
181
+ )
182
+
183
+
184
+ @dataclasses.dataclass
185
+ class BroadcastSource:
186
+ input: str
187
+ input_format: str
188
+ bitrate: int
189
+
190
+
191
+ @dataclasses.dataclass
192
+ class AudioSource:
193
+ audio_input: audio_io.AudioInput
194
+ pcm_format: audio_io.PcmFormat
195
+ pcm_bit_depth: int | None
196
+ lc3_encoder: lc3.Encoder
197
+ lc3_frame_samples: int
198
+ lc3_frame_size: int
199
+ audio_frames: AsyncGenerator
200
+
201
+
106
202
  # -----------------------------------------------------------------------------
107
203
  # Scan For Broadcasts
108
204
  # -----------------------------------------------------------------------------
@@ -113,12 +209,13 @@ class BroadcastScanner(bumble.utils.EventEmitter):
113
209
  sync: bumble.device.PeriodicAdvertisingSync
114
210
  broadcast_id: int
115
211
  rssi: int = 0
116
- public_broadcast_announcement: Optional[pbp.PublicBroadcastAnnouncement] = None
117
- broadcast_audio_announcement: Optional[bap.BroadcastAudioAnnouncement] = None
118
- basic_audio_announcement: Optional[bap.BasicAudioAnnouncement] = None
119
- appearance: Optional[core.Appearance] = None
120
- biginfo: Optional[bumble.device.BigInfoAdvertisement] = None
121
- manufacturer_data: Optional[tuple[str, bytes]] = None
212
+ public_broadcast_announcement: pbp.PublicBroadcastAnnouncement | None = None
213
+ broadcast_audio_announcement: bap.BroadcastAudioAnnouncement | None = None
214
+ basic_audio_announcement: bap.BasicAudioAnnouncement | None = None
215
+ appearance: core.Appearance | None = None
216
+ biginfo: bumble.device.BigInfoAdvertisement | None = None
217
+ manufacturer_data: tuple[str, bytes] | None = None
218
+ device_name: str | None = None
122
219
 
123
220
  def __post_init__(self) -> None:
124
221
  super().__init__()
@@ -146,6 +243,10 @@ class BroadcastScanner(bumble.utils.EventEmitter):
146
243
  )
147
244
  continue
148
245
 
246
+ self.device_name = advertisement.data.get(
247
+ core.AdvertisingData.Type.COMPLETE_LOCAL_NAME
248
+ )
249
+
149
250
  self.appearance = advertisement.data.get(
150
251
  core.AdvertisingData.Type.APPEARANCE
151
252
  )
@@ -170,11 +271,13 @@ class BroadcastScanner(bumble.utils.EventEmitter):
170
271
  color(self.sync.state.name, 'green'),
171
272
  )
172
273
  if self.name is not None:
173
- print(f' {color("Name", "cyan")}: {self.name}')
274
+ print(f' {color("Broadcast Name", "cyan")}: {self.name}')
275
+ if self.device_name:
276
+ print(f' {color("Device Name", "cyan")}: {self.device_name}')
174
277
  if self.appearance:
175
- print(f' {color("Appearance", "cyan")}: {str(self.appearance)}')
176
- print(f' {color("RSSI", "cyan")}: {self.rssi}')
177
- print(f' {color("SID", "cyan")}: {self.sync.sid}')
278
+ print(f' {color("Appearance", "cyan")}: {str(self.appearance)}')
279
+ print(f' {color("RSSI", "cyan")}: {self.rssi}')
280
+ print(f' {color("SID", "cyan")}: {self.sync.sid}')
178
281
 
179
282
  if self.manufacturer_data:
180
283
  print(
@@ -184,17 +287,22 @@ class BroadcastScanner(bumble.utils.EventEmitter):
184
287
 
185
288
  if self.broadcast_audio_announcement:
186
289
  print(
187
- f' {color("Broadcast ID", "cyan")}: '
290
+ f' {color("Broadcast ID", "cyan")}: '
188
291
  f'{self.broadcast_audio_announcement.broadcast_id}'
189
292
  )
190
293
 
191
294
  if self.public_broadcast_announcement:
192
295
  print(
193
- f' {color("Features", "cyan")}: '
296
+ f' {color("Features", "cyan")}: '
194
297
  f'{self.public_broadcast_announcement.features.name}'
195
298
  )
196
- print(f' {color("Metadata", "cyan")}:')
197
- print(self.public_broadcast_announcement.metadata.pretty_print(' '))
299
+ if self.public_broadcast_announcement.metadata.entries:
300
+ print(f' {color("Metadata", "cyan")}: ')
301
+ print(
302
+ self.public_broadcast_announcement.metadata.pretty_print(
303
+ ' '
304
+ )
305
+ )
198
306
 
199
307
  if self.basic_audio_announcement:
200
308
  print(color(' Audio:', 'cyan'))
@@ -210,22 +318,24 @@ class BroadcastScanner(bumble.utils.EventEmitter):
210
318
  color(' Coding Format: ', 'green'),
211
319
  subgroup.codec_id.codec_id.name,
212
320
  )
213
- print(
214
- color(' Company ID: ', 'green'),
215
- subgroup.codec_id.company_id,
216
- )
217
- print(
218
- color(' Vendor Specific Codec ID:', 'green'),
219
- subgroup.codec_id.vendor_specific_codec_id,
220
- )
321
+ if subgroup.codec_id.company_id:
322
+ print(
323
+ color(' Company ID: ', 'green'),
324
+ subgroup.codec_id.company_id,
325
+ )
326
+ print(
327
+ color(' Vendor Specific Codec ID:', 'green'),
328
+ subgroup.codec_id.vendor_specific_codec_id,
329
+ )
221
330
  print(color(' Codec Config:', 'yellow'))
222
331
  print(
223
332
  codec_config_string(
224
333
  subgroup.codec_specific_configuration, ' '
225
334
  ),
226
335
  )
227
- print(color(' Metadata: ', 'yellow'))
228
- print(subgroup.metadata.pretty_print(' '))
336
+ if subgroup.metadata.entries:
337
+ print(color(' Metadata: ', 'yellow'))
338
+ print(subgroup.metadata.pretty_print(' '))
229
339
 
230
340
  for bis in subgroup.bis:
231
341
  print(color(f' BIS [{bis.index}]:', 'yellow'))
@@ -292,7 +402,7 @@ class BroadcastScanner(bumble.utils.EventEmitter):
292
402
  self.device = device
293
403
  self.filter_duplicates = filter_duplicates
294
404
  self.sync_timeout = sync_timeout
295
- self.broadcasts = dict[hci.Address, BroadcastScanner.Broadcast]()
405
+ self.broadcasts = dict[tuple[hci.Address, int], BroadcastScanner.Broadcast]()
296
406
  device.on('advertisement', self.on_advertisement)
297
407
 
298
408
  async def start(self) -> None:
@@ -310,7 +420,7 @@ class BroadcastScanner(bumble.utils.EventEmitter):
310
420
  core.AdvertisingData.Type.SERVICE_DATA_16_BIT_UUID
311
421
  )
312
422
  ) or not (
313
- broadcast_audio_announcement := next(
423
+ broadcast_audio_announcement_ad := next(
314
424
  (
315
425
  ad
316
426
  for ad in ads
@@ -325,7 +435,13 @@ class BroadcastScanner(bumble.utils.EventEmitter):
325
435
  core.AdvertisingData.Type.BROADCAST_NAME
326
436
  )
327
437
 
328
- if broadcast := self.broadcasts.get(advertisement.address):
438
+ broadcast_audio_announcement = bap.BroadcastAudioAnnouncement.from_bytes(
439
+ broadcast_audio_announcement_ad[1]
440
+ )
441
+
442
+ if broadcast := self.broadcasts.get(
443
+ (advertisement.address, broadcast_audio_announcement.broadcast_id)
444
+ ):
329
445
  broadcast.update(advertisement)
330
446
  return
331
447
 
@@ -333,9 +449,7 @@ class BroadcastScanner(bumble.utils.EventEmitter):
333
449
  self.on_new_broadcast(
334
450
  broadcast_name[0] if broadcast_name else None,
335
451
  advertisement,
336
- bap.BroadcastAudioAnnouncement.from_bytes(
337
- broadcast_audio_announcement[1]
338
- ).broadcast_id,
452
+ broadcast_audio_announcement.broadcast_id,
339
453
  )
340
454
  )
341
455
 
@@ -353,12 +467,12 @@ class BroadcastScanner(bumble.utils.EventEmitter):
353
467
  )
354
468
  broadcast = self.Broadcast(name, periodic_advertising_sync, broadcast_id)
355
469
  broadcast.update(advertisement)
356
- self.broadcasts[advertisement.address] = broadcast
470
+ self.broadcasts[(advertisement.address, broadcast_id)] = broadcast
357
471
  periodic_advertising_sync.on('loss', lambda: self.on_broadcast_loss(broadcast))
358
472
  self.emit('new_broadcast', broadcast)
359
473
 
360
- def on_broadcast_loss(self, broadcast: Broadcast) -> None:
361
- del self.broadcasts[broadcast.sync.advertiser_address]
474
+ def on_broadcast_loss(self, broadcast: BroadcastScanner.Broadcast) -> None:
475
+ del self.broadcasts[(broadcast.sync.advertiser_address, broadcast.broadcast_id)]
362
476
  bumble.utils.AsyncRunner.spawn(broadcast.sync.terminate())
363
477
  self.emit('broadcast_loss', broadcast)
364
478
 
@@ -434,7 +548,7 @@ async def create_device(transport: str) -> AsyncGenerator[bumble.device.Device,
434
548
 
435
549
 
436
550
  async def find_broadcast_by_name(
437
- device: bumble.device.Device, name: Optional[str]
551
+ device: bumble.device.Device, name: str | None
438
552
  ) -> BroadcastScanner.Broadcast:
439
553
  result = asyncio.get_running_loop().create_future()
440
554
 
@@ -462,205 +576,198 @@ async def find_broadcast_by_name(
462
576
 
463
577
 
464
578
  async def run_scan(
465
- filter_duplicates: bool, sync_timeout: float, transport: str
579
+ device: bumble.device.Device, filter_duplicates: bool, sync_timeout: float
466
580
  ) -> None:
467
- async with create_device(transport) as device:
468
- if not device.supports_le_periodic_advertising:
469
- print(color('Periodic advertising not supported', 'red'))
470
- return
581
+ if not device.supports_le_periodic_advertising:
582
+ print(color('Periodic advertising not supported', 'red'))
583
+ return
471
584
 
472
- scanner = PrintingBroadcastScanner(device, filter_duplicates, sync_timeout)
473
- await scanner.start()
474
- await asyncio.get_running_loop().create_future()
585
+ scanner = PrintingBroadcastScanner(device, filter_duplicates, sync_timeout)
586
+ await scanner.start()
587
+ await asyncio.get_running_loop().create_future()
475
588
 
476
589
 
477
590
  async def run_assist(
478
- broadcast_name: Optional[str],
479
- source_id: Optional[int],
591
+ device: bumble.device.Device,
592
+ broadcast_name: str | None,
593
+ source_id: int | None,
480
594
  command: str,
481
- transport: str,
482
595
  address: str,
483
596
  ) -> None:
484
- async with create_device(transport) as device:
485
- if not device.supports_le_periodic_advertising:
486
- print(color('Periodic advertising not supported', 'red'))
487
- return
597
+ if not device.supports_le_periodic_advertising:
598
+ print(color('Periodic advertising not supported', 'red'))
599
+ return
488
600
 
489
- # Connect to the server
490
- print(f'=== Connecting to {address}...')
491
- connection = await device.connect(address)
492
- peer = bumble.device.Peer(connection)
493
- print(f'=== Connected to {peer}')
601
+ # Connect to the server
602
+ print(f'=== Connecting to {address}...')
603
+ connection = await device.connect(address)
604
+ peer = bumble.device.Peer(connection)
605
+ print(f'=== Connected to {peer}')
494
606
 
495
- print("+++ Encrypting connection...")
496
- await peer.connection.encrypt()
497
- print("+++ Connection encrypted")
607
+ print("+++ Encrypting connection...")
608
+ await peer.connection.encrypt()
609
+ print("+++ Connection encrypted")
498
610
 
499
- # Request a larger MTU
500
- mtu = AURACAST_DEFAULT_ATT_MTU
501
- print(color(f'$$$ Requesting MTU={mtu}', 'yellow'))
502
- await peer.request_mtu(mtu)
611
+ # Request a larger MTU
612
+ mtu = AURACAST_DEFAULT_ATT_MTU
613
+ print(color(f'$$$ Requesting MTU={mtu}', 'yellow'))
614
+ await peer.request_mtu(mtu)
503
615
 
504
- # Get the BASS service
505
- bass_client = await peer.discover_service_and_create_proxy(
506
- bass.BroadcastAudioScanServiceProxy
507
- )
616
+ # Get the BASS service
617
+ bass_client = await peer.discover_service_and_create_proxy(
618
+ bass.BroadcastAudioScanServiceProxy
619
+ )
508
620
 
509
- # Check that the service was found
510
- if not bass_client:
511
- print(color('!!! Broadcast Audio Scan Service not found', 'red'))
512
- return
621
+ # Check that the service was found
622
+ if not bass_client:
623
+ print(color('!!! Broadcast Audio Scan Service not found', 'red'))
624
+ return
513
625
 
514
- # Subscribe to and read the broadcast receive state characteristics
515
- def on_broadcast_receive_state_update(
516
- value: bass.BroadcastReceiveState, index: int
517
- ) -> None:
626
+ # Subscribe to and read the broadcast receive state characteristics
627
+ def on_broadcast_receive_state_update(
628
+ value: bass.BroadcastReceiveState | None, index: int
629
+ ) -> None:
630
+ if value is not None:
518
631
  print(
519
632
  f"{color(f'Broadcast Receive State Update [{index}]:', 'green')} {value}"
520
633
  )
521
634
 
522
- for i, broadcast_receive_state in enumerate(
523
- bass_client.broadcast_receive_states
524
- ):
525
- try:
526
- await broadcast_receive_state.subscribe(
527
- functools.partial(on_broadcast_receive_state_update, index=i)
528
- )
529
- except core.ProtocolError as error:
530
- print(
531
- color(
532
- '!!! Failed to subscribe to Broadcast Receive State characteristic',
533
- 'red',
534
- ),
535
- error,
536
- )
537
- value = await broadcast_receive_state.read_value()
635
+ for i, broadcast_receive_state in enumerate(bass_client.broadcast_receive_states):
636
+ try:
637
+ await broadcast_receive_state.subscribe(
638
+ functools.partial(on_broadcast_receive_state_update, index=i)
639
+ )
640
+ except core.ProtocolError as error:
538
641
  print(
539
- f'{color(f"Initial Broadcast Receive State [{i}]:", "green")} {value}'
642
+ color(
643
+ '!!! Failed to subscribe to Broadcast Receive State characteristic',
644
+ 'red',
645
+ ),
646
+ error,
540
647
  )
648
+ value = await broadcast_receive_state.read_value()
649
+ print(f'{color(f"Initial Broadcast Receive State [{i}]:", "green")} {value}')
541
650
 
542
- if command == 'monitor-state':
543
- await peer.sustain()
544
- return
545
-
546
- if command == 'add-source':
547
- # Find the requested broadcast
548
- await bass_client.remote_scan_started()
549
- if broadcast_name:
550
- print(color('Scanning for broadcast:', 'cyan'), broadcast_name)
551
- else:
552
- print(color('Scanning for any broadcast', 'cyan'))
553
- broadcast = await find_broadcast_by_name(device, broadcast_name)
651
+ if command == 'monitor-state':
652
+ await peer.sustain()
653
+ return
554
654
 
555
- if broadcast.broadcast_audio_announcement is None:
556
- print(color('No broadcast audio announcement found', 'red'))
557
- return
655
+ if command == 'add-source':
656
+ # Find the requested broadcast
657
+ await bass_client.remote_scan_started()
658
+ if broadcast_name:
659
+ print(color('Scanning for broadcast:', 'cyan'), broadcast_name)
660
+ else:
661
+ print(color('Scanning for any broadcast', 'cyan'))
662
+ broadcast = await find_broadcast_by_name(device, broadcast_name)
558
663
 
559
- if (
560
- broadcast.basic_audio_announcement is None
561
- or not broadcast.basic_audio_announcement.subgroups
562
- ):
563
- print(color('No subgroups found', 'red'))
564
- return
664
+ if broadcast.broadcast_audio_announcement is None:
665
+ print(color('No broadcast audio announcement found', 'red'))
666
+ return
565
667
 
566
- # Add the source
567
- print(color('Adding source:', 'blue'), broadcast.sync.advertiser_address)
568
- await bass_client.add_source(
569
- broadcast.sync.advertiser_address,
570
- broadcast.sync.sid,
571
- broadcast.broadcast_audio_announcement.broadcast_id,
572
- bass.PeriodicAdvertisingSyncParams.SYNCHRONIZE_TO_PA_PAST_AVAILABLE,
573
- 0xFFFF,
574
- [
575
- bass.SubgroupInfo(
576
- bass.SubgroupInfo.ANY_BIS,
577
- bytes(broadcast.basic_audio_announcement.subgroups[0].metadata),
578
- )
579
- ],
580
- )
668
+ if (
669
+ broadcast.basic_audio_announcement is None
670
+ or not broadcast.basic_audio_announcement.subgroups
671
+ ):
672
+ print(color('No subgroups found', 'red'))
673
+ return
581
674
 
582
- # Initiate a PA Sync Transfer
583
- await broadcast.sync.transfer(peer.connection)
675
+ # Add the source
676
+ print(color('Adding source:', 'blue'), broadcast.sync.advertiser_address)
677
+ await bass_client.add_source(
678
+ broadcast.sync.advertiser_address,
679
+ broadcast.sync.sid,
680
+ broadcast.broadcast_audio_announcement.broadcast_id,
681
+ bass.PeriodicAdvertisingSyncParams.SYNCHRONIZE_TO_PA_PAST_AVAILABLE,
682
+ 0xFFFF,
683
+ [
684
+ bass.SubgroupInfo(
685
+ bass.SubgroupInfo.ANY_BIS,
686
+ bytes(broadcast.basic_audio_announcement.subgroups[0].metadata),
687
+ )
688
+ ],
689
+ )
584
690
 
585
- # Notify the sink that we're done scanning.
586
- await bass_client.remote_scan_stopped()
691
+ # Initiate a PA Sync Transfer
692
+ await broadcast.sync.transfer(peer.connection)
587
693
 
588
- await peer.sustain()
589
- return
694
+ # Notify the sink that we're done scanning.
695
+ await bass_client.remote_scan_stopped()
590
696
 
591
- if command == 'modify-source':
592
- if source_id is None:
593
- print(color('!!! modify-source requires --source-id'))
594
- return
697
+ await peer.sustain()
698
+ return
595
699
 
596
- # Find the requested broadcast
597
- await bass_client.remote_scan_started()
598
- if broadcast_name:
599
- print(color('Scanning for broadcast:', 'cyan'), broadcast_name)
600
- else:
601
- print(color('Scanning for any broadcast', 'cyan'))
602
- broadcast = await find_broadcast_by_name(device, broadcast_name)
700
+ if command == 'modify-source':
701
+ if source_id is None:
702
+ print(color('!!! modify-source requires --source-id'))
703
+ return
603
704
 
604
- if broadcast.broadcast_audio_announcement is None:
605
- print(color('No broadcast audio announcement found', 'red'))
606
- return
705
+ # Find the requested broadcast
706
+ await bass_client.remote_scan_started()
707
+ if broadcast_name:
708
+ print(color('Scanning for broadcast:', 'cyan'), broadcast_name)
709
+ else:
710
+ print(color('Scanning for any broadcast', 'cyan'))
711
+ broadcast = await find_broadcast_by_name(device, broadcast_name)
607
712
 
608
- if (
609
- broadcast.basic_audio_announcement is None
610
- or not broadcast.basic_audio_announcement.subgroups
611
- ):
612
- print(color('No subgroups found', 'red'))
613
- return
713
+ if broadcast.broadcast_audio_announcement is None:
714
+ print(color('No broadcast audio announcement found', 'red'))
715
+ return
614
716
 
615
- # Modify the source
616
- print(
617
- color('Modifying source:', 'blue'),
618
- source_id,
619
- )
620
- await bass_client.modify_source(
621
- source_id,
622
- bass.PeriodicAdvertisingSyncParams.SYNCHRONIZE_TO_PA_PAST_NOT_AVAILABLE,
623
- 0xFFFF,
624
- [
625
- bass.SubgroupInfo(
626
- bass.SubgroupInfo.ANY_BIS,
627
- bytes(broadcast.basic_audio_announcement.subgroups[0].metadata),
628
- )
629
- ],
630
- )
631
- await peer.sustain()
717
+ if (
718
+ broadcast.basic_audio_announcement is None
719
+ or not broadcast.basic_audio_announcement.subgroups
720
+ ):
721
+ print(color('No subgroups found', 'red'))
632
722
  return
633
723
 
634
- if command == 'remove-source':
635
- if source_id is None:
636
- print(color('!!! remove-source requires --source-id'))
637
- return
724
+ # Modify the source
725
+ print(
726
+ color('Modifying source:', 'blue'),
727
+ source_id,
728
+ )
729
+ await bass_client.modify_source(
730
+ source_id,
731
+ bass.PeriodicAdvertisingSyncParams.SYNCHRONIZE_TO_PA_PAST_NOT_AVAILABLE,
732
+ 0xFFFF,
733
+ [
734
+ bass.SubgroupInfo(
735
+ bass.SubgroupInfo.ANY_BIS,
736
+ bytes(broadcast.basic_audio_announcement.subgroups[0].metadata),
737
+ )
738
+ ],
739
+ )
740
+ await peer.sustain()
741
+ return
638
742
 
639
- # Remove the source
640
- print(color('Removing source:', 'blue'), source_id)
641
- await bass_client.remove_source(source_id)
642
- await peer.sustain()
743
+ if command == 'remove-source':
744
+ if source_id is None:
745
+ print(color('!!! remove-source requires --source-id'))
643
746
  return
644
747
 
645
- print(color(f'!!! invalid command {command}'))
748
+ # Remove the source
749
+ print(color('Removing source:', 'blue'), source_id)
750
+ await bass_client.remove_source(source_id)
751
+ await peer.sustain()
752
+ return
646
753
 
754
+ print(color(f'!!! invalid command {command}'))
647
755
 
648
- async def run_pair(transport: str, address: str) -> None:
649
- async with create_device(transport) as device:
650
756
 
651
- # Connect to the server
652
- print(f'=== Connecting to {address}...')
653
- async with device.connect_as_gatt(address) as peer:
654
- print(f'=== Connected to {peer}')
757
+ async def run_pair(device: bumble.device.Device, address: str) -> None:
758
+ # Connect to the server
759
+ print(f'=== Connecting to {address}...')
760
+ async with device.connect_as_gatt(address) as peer:
761
+ print(f'=== Connected to {peer}')
655
762
 
656
- print("+++ Initiating pairing...")
657
- await peer.connection.pair()
658
- print("+++ Paired")
763
+ print("+++ Initiating pairing...")
764
+ await peer.connection.pair()
765
+ print("+++ Paired")
659
766
 
660
767
 
661
768
  async def run_receive(
662
- transport: str,
663
- broadcast_id: Optional[int],
769
+ device: bumble.device.Device,
770
+ broadcast_id: int | None,
664
771
  output: str,
665
772
  broadcast_code: str | None,
666
773
  sync_timeout: float,
@@ -674,300 +781,425 @@ async def run_receive(
674
781
  print(error)
675
782
  return
676
783
 
677
- async with create_device(transport) as device:
678
- if not device.supports_le_periodic_advertising:
679
- print(color('Periodic advertising not supported', 'red'))
680
- return
784
+ if not device.supports_le_periodic_advertising:
785
+ print(color('Periodic advertising not supported', 'red'))
786
+ return
681
787
 
682
- scanner = BroadcastScanner(device, False, sync_timeout)
683
- scan_result: asyncio.Future[BroadcastScanner.Broadcast] = (
684
- asyncio.get_running_loop().create_future()
685
- )
788
+ scanner = BroadcastScanner(device, False, sync_timeout)
789
+ scan_result: asyncio.Future[BroadcastScanner.Broadcast] = (
790
+ asyncio.get_running_loop().create_future()
791
+ )
686
792
 
687
- def on_new_broadcast(broadcast: BroadcastScanner.Broadcast) -> None:
688
- if scan_result.done():
689
- return
690
- if broadcast_id is None or broadcast.broadcast_id == broadcast_id:
691
- scan_result.set_result(broadcast)
692
-
693
- scanner.on('new_broadcast', on_new_broadcast)
694
- await scanner.start()
695
- print('Start scanning...')
696
- broadcast = await scan_result
697
- print('Advertisement found:')
698
- broadcast.print()
699
- basic_audio_announcement_scanned = asyncio.Event()
700
-
701
- def on_change() -> None:
702
- if (
703
- broadcast.basic_audio_announcement and broadcast.biginfo
704
- ) and not basic_audio_announcement_scanned.is_set():
705
- basic_audio_announcement_scanned.set()
706
-
707
- broadcast.on('change', on_change)
708
- if not broadcast.basic_audio_announcement or not broadcast.biginfo:
709
- print('Wait for Basic Audio Announcement and BIG Info...')
710
- await basic_audio_announcement_scanned.wait()
711
- print('Basic Audio Announcement found')
712
- broadcast.print()
713
- print('Stop scanning')
714
- await scanner.stop()
715
- print('Start sync to BIG')
716
-
717
- assert broadcast.basic_audio_announcement
718
- subgroup = broadcast.basic_audio_announcement.subgroups[subgroup_index]
719
- configuration = subgroup.codec_specific_configuration
720
- assert configuration
721
- assert (sampling_frequency := configuration.sampling_frequency)
722
- assert (frame_duration := configuration.frame_duration)
723
-
724
- big_sync = await device.create_big_sync(
725
- broadcast.sync,
726
- bumble.device.BigSyncParameters(
727
- big_sync_timeout=0x4000,
728
- bis=[bis.index for bis in subgroup.bis],
729
- broadcast_code=(
730
- broadcast_code_bytes(broadcast_code) if broadcast_code else None
731
- ),
793
+ def on_new_broadcast(broadcast: BroadcastScanner.Broadcast) -> None:
794
+ if scan_result.done():
795
+ return
796
+ if broadcast_id is None or broadcast.broadcast_id == broadcast_id:
797
+ scan_result.set_result(broadcast)
798
+
799
+ scanner.on('new_broadcast', on_new_broadcast)
800
+ await scanner.start()
801
+ print('Start scanning...')
802
+ broadcast = await scan_result
803
+ print('Advertisement found:')
804
+ broadcast.print()
805
+ basic_audio_announcement_scanned = asyncio.Event()
806
+
807
+ def on_change() -> None:
808
+ if (
809
+ broadcast.basic_audio_announcement and broadcast.biginfo
810
+ ) and not basic_audio_announcement_scanned.is_set():
811
+ basic_audio_announcement_scanned.set()
812
+
813
+ broadcast.on('change', on_change)
814
+ if not broadcast.basic_audio_announcement or not broadcast.biginfo:
815
+ print('Wait for Basic Audio Announcement and BIG Info...')
816
+ await basic_audio_announcement_scanned.wait()
817
+ print('Basic Audio Announcement found')
818
+ broadcast.print()
819
+ print('Stop scanning')
820
+ await scanner.stop()
821
+ print('Start sync to BIG')
822
+
823
+ assert broadcast.basic_audio_announcement
824
+ subgroup = broadcast.basic_audio_announcement.subgroups[subgroup_index]
825
+ configuration = subgroup.codec_specific_configuration
826
+ assert configuration
827
+ assert (sampling_frequency := configuration.sampling_frequency)
828
+ assert (frame_duration := configuration.frame_duration)
829
+
830
+ big_sync = await device.create_big_sync(
831
+ broadcast.sync,
832
+ bumble.device.BigSyncParameters(
833
+ big_sync_timeout=0x4000,
834
+ bis=[bis.index for bis in subgroup.bis],
835
+ broadcast_code=(
836
+ broadcast_code_bytes(broadcast_code) if broadcast_code else None
732
837
  ),
733
- )
734
- num_bis = len(big_sync.bis_links)
735
- decoder = lc3.Decoder(
736
- frame_duration_us=frame_duration.us,
737
- sample_rate_hz=sampling_frequency.hz,
738
- num_channels=num_bis,
739
- )
740
- lc3_queues: list[collections.deque[bytes]] = [
741
- collections.deque() for i in range(num_bis)
742
- ]
743
- packet_stats = [0, 0]
744
-
745
- async with contextlib.AsyncExitStack() as stack:
746
- audio_output = await audio_io.create_audio_output(output)
747
- stack.push_async_callback(audio_output.aclose)
748
- await audio_output.open(
749
- audio_io.PcmFormat(
750
- audio_io.PcmFormat.Endianness.LITTLE,
751
- audio_io.PcmFormat.SampleType.FLOAT32,
752
- sampling_frequency.hz,
753
- num_bis,
754
- )
838
+ ),
839
+ )
840
+ num_bis = len(big_sync.bis_links)
841
+ decoder = lc3.Decoder(
842
+ frame_duration_us=frame_duration.us,
843
+ sample_rate_hz=sampling_frequency.hz,
844
+ num_channels=num_bis,
845
+ )
846
+ lc3_queues: list[collections.deque[bytes]] = [
847
+ collections.deque() for i in range(num_bis)
848
+ ]
849
+ packet_stats = [0, 0]
850
+
851
+ audio_output = await audio_io.create_audio_output(output)
852
+ # This try should be replaced with contextlib.aclosing() when python 3.9 is no
853
+ # longer needed.
854
+ try:
855
+ await audio_output.open(
856
+ audio_io.PcmFormat(
857
+ audio_io.PcmFormat.Endianness.LITTLE,
858
+ audio_io.PcmFormat.SampleType.FLOAT32,
859
+ sampling_frequency.hz,
860
+ num_bis,
755
861
  )
862
+ )
756
863
 
757
- def sink(queue: collections.deque[bytes], packet: hci.HCI_IsoDataPacket):
758
- # TODO: re-assemble fragments and detect errors
759
- queue.append(packet.iso_sdu_fragment)
864
+ def sink(queue: collections.deque[bytes], packet: hci.HCI_IsoDataPacket):
865
+ # TODO: re-assemble fragments and detect errors
866
+ queue.append(packet.iso_sdu_fragment)
760
867
 
761
- while all(lc3_queues):
762
- # This assumes SDUs contain one LC3 frame each, which may not
763
- # be correct for all cases. TODO: revisit this assumption.
764
- frame = b''.join([lc3_queue.popleft() for lc3_queue in lc3_queues])
765
- if not frame:
766
- print(color('!!! received empty frame', 'red'))
767
- continue
868
+ while all(lc3_queues):
869
+ # This assumes SDUs contain one LC3 frame each, which may not
870
+ # be correct for all cases. TODO: revisit this assumption.
871
+ frame = b''.join([lc3_queue.popleft() for lc3_queue in lc3_queues])
872
+ if not frame:
873
+ print(color('!!! received empty frame', 'red'))
874
+ continue
768
875
 
769
- packet_stats[0] += len(frame)
770
- packet_stats[1] += 1
771
- print(
772
- f'\rRECEIVED: {packet_stats[0]} bytes in '
773
- f'{packet_stats[1]} packets',
774
- end='',
775
- )
876
+ packet_stats[0] += len(frame)
877
+ packet_stats[1] += 1
878
+ print(
879
+ f'\rRECEIVED: {packet_stats[0]} bytes in '
880
+ f'{packet_stats[1]} packets',
881
+ end='',
882
+ )
776
883
 
777
- try:
778
- pcm = decoder.decode(frame).tobytes()
779
- except lc3.BaseError as error:
780
- print(color(f'!!! LC3 decoding error: {error}'))
781
- continue
884
+ try:
885
+ pcm = decoder.decode(frame).tobytes()
886
+ except lc3.BaseError as error:
887
+ print(color(f'!!! LC3 decoding error: {error}'))
888
+ continue
782
889
 
783
- audio_output.write(pcm)
890
+ audio_output.write(pcm)
784
891
 
785
- for i, bis_link in enumerate(big_sync.bis_links):
786
- print(f'Setup ISO for BIS {bis_link.handle}')
787
- bis_link.sink = functools.partial(sink, lc3_queues[i])
788
- await bis_link.setup_data_path(
789
- direction=bis_link.Direction.CONTROLLER_TO_HOST
790
- )
892
+ for i, bis_link in enumerate(big_sync.bis_links):
893
+ print(f'Setup ISO for BIS {bis_link.handle}')
894
+ bis_link.sink = functools.partial(sink, lc3_queues[i])
895
+ await bis_link.setup_data_path(
896
+ direction=bis_link.Direction.CONTROLLER_TO_HOST
897
+ )
791
898
 
792
- terminated = asyncio.Event()
793
- big_sync.on(big_sync.Event.TERMINATION, lambda _: terminated.set())
794
- await terminated.wait()
899
+ terminated = asyncio.Event()
900
+ big_sync.on(big_sync.Event.TERMINATION, lambda _: terminated.set())
901
+ await terminated.wait()
902
+ finally:
903
+ await audio_output.aclose()
795
904
 
796
905
 
797
906
  async def run_transmit(
798
- transport: str,
799
- broadcast_id: int,
800
- broadcast_code: str | None,
801
- broadcast_name: str,
802
- bitrate: int,
803
- manufacturer_data: tuple[int, bytes] | None,
804
- input: str,
805
- input_format: str,
907
+ device: bumble.device.Device, broadcasts: Iterable[Broadcast]
806
908
  ) -> None:
807
- # Run a pre-flight check for the input.
909
+ # Run a pre-flight check for the input(s).
808
910
  try:
809
- if not audio_io.check_audio_input(input):
810
- return
911
+ for broadcast in broadcasts:
912
+ for source in broadcast.sources:
913
+ if not audio_io.check_audio_input(source.input):
914
+ return
811
915
  except ValueError as error:
812
916
  print(error)
813
917
  return
814
918
 
815
- async with create_device(transport) as device:
816
- if not device.supports_le_periodic_advertising:
817
- print(color('Periodic advertising not supported', 'red'))
818
- return
919
+ if not device.supports_le_periodic_advertising:
920
+ print(color('Periodic advertising not supported', 'red'))
921
+ return
819
922
 
820
- basic_audio_announcement = bap.BasicAudioAnnouncement(
821
- presentation_delay=40000,
822
- subgroups=[
823
- bap.BasicAudioAnnouncement.Subgroup(
824
- codec_id=hci.CodingFormat(codec_id=hci.CodecID.LC3),
825
- codec_specific_configuration=bap.CodecSpecificConfiguration(
826
- sampling_frequency=bap.SamplingFrequency.FREQ_48000,
827
- frame_duration=bap.FrameDuration.DURATION_10000_US,
828
- octets_per_codec_frame=100,
829
- ),
830
- metadata=le_audio.Metadata(
831
- [
832
- le_audio.Metadata.Entry(
833
- tag=le_audio.Metadata.Tag.LANGUAGE, data=b'eng'
834
- ),
835
- le_audio.Metadata.Entry(
836
- tag=le_audio.Metadata.Tag.PROGRAM_INFO, data=b'Disco'
837
- ),
838
- ]
839
- ),
840
- bis=[
841
- bap.BasicAudioAnnouncement.BIS(
842
- index=1,
843
- codec_specific_configuration=bap.CodecSpecificConfiguration(
844
- audio_channel_allocation=bap.AudioLocation.FRONT_LEFT
923
+ def on_flow():
924
+ pending = []
925
+ queued = []
926
+ completed = []
927
+ for broadcast in broadcasts:
928
+ for iso_queue in broadcast.iso_queues:
929
+ data_packet_queue = iso_queue.data_packet_queue
930
+ pending.append(str(data_packet_queue.pending))
931
+ queued.append(str(data_packet_queue.queued))
932
+ completed.append(str(data_packet_queue.completed))
933
+ print(
934
+ f'\rPACKETS: '
935
+ f'pending={",".join(pending)} | '
936
+ f'queued={",".join(queued)} | '
937
+ f'completed={",".join(completed)}',
938
+ end='',
939
+ )
940
+
941
+ audio_inputs: list[audio_io.AudioInput] = []
942
+ try:
943
+ # Setup audio sources
944
+ for broadcast_index, broadcast in enumerate(broadcasts):
945
+ channel_count = 0
946
+ max_lc3_frame_size = 0
947
+ max_sample_rate = 0
948
+ for source in broadcast.sources:
949
+ print(f'Setting up audio input: {source.input}')
950
+
951
+ # Open the audio input
952
+ audio_input = await audio_io.create_audio_input(
953
+ source.input, source.input_format
954
+ )
955
+ pcm_format = await audio_input.open()
956
+ audio_inputs.append(audio_input)
957
+
958
+ # Check that the number of channels is supported
959
+ if pcm_format.channels not in (1, 2):
960
+ print("Only 1 and 2 channels PCM configurations are supported")
961
+ return
962
+ channel_count += pcm_format.channels
963
+
964
+ # Check that the sample type is supported
965
+ if pcm_format.sample_type == audio_io.PcmFormat.SampleType.INT16:
966
+ pcm_bit_depth = 16
967
+ elif pcm_format.sample_type == audio_io.PcmFormat.SampleType.FLOAT32:
968
+ pcm_bit_depth = None
969
+ else:
970
+ print("Only INT16 and FLOAT32 sample types are supported")
971
+ return
972
+
973
+ # Check that the sample rate is supported
974
+ if pcm_format.sample_rate not in (16000, 24000, 48000):
975
+ print(f'Sample rate {pcm_format.sample_rate} not supported')
976
+ return
977
+ max_sample_rate = max(max_sample_rate, pcm_format.sample_rate)
978
+
979
+ # Compute LC3 parameters and create and encoder
980
+ encoder = lc3.Encoder(
981
+ frame_duration_us=AURACAST_DEFAULT_FRAME_DURATION,
982
+ sample_rate_hz=pcm_format.sample_rate,
983
+ num_channels=pcm_format.channels,
984
+ input_sample_rate_hz=pcm_format.sample_rate,
985
+ )
986
+ lc3_frame_samples = encoder.get_frame_samples()
987
+ lc3_frame_size = encoder.get_frame_bytes(source.bitrate)
988
+ max_lc3_frame_size = max(max_lc3_frame_size, lc3_frame_size)
989
+ print(
990
+ f'Encoding {source.input} with {lc3_frame_samples} '
991
+ f'PCM samples per {lc3_frame_size} byte frame'
992
+ )
993
+
994
+ broadcast.audio_sources.append(
995
+ AudioSource(
996
+ audio_input=audio_input,
997
+ pcm_format=pcm_format,
998
+ pcm_bit_depth=pcm_bit_depth,
999
+ lc3_encoder=encoder,
1000
+ lc3_frame_samples=lc3_frame_samples,
1001
+ lc3_frame_size=lc3_frame_size,
1002
+ audio_frames=audio_input.frames(lc3_frame_samples),
1003
+ )
1004
+ )
1005
+
1006
+ # Setup advertising and BIGs
1007
+ metadata = le_audio.Metadata()
1008
+ if broadcast.language is not None:
1009
+ metadata.entries.append(
1010
+ le_audio.Metadata.Entry(
1011
+ tag=le_audio.Metadata.Tag.LANGUAGE,
1012
+ data=broadcast.language.encode('utf-8'),
1013
+ )
1014
+ )
1015
+ if broadcast.program_info is not None:
1016
+ metadata.entries.append(
1017
+ le_audio.Metadata.Entry(
1018
+ tag=le_audio.Metadata.Tag.PROGRAM_INFO,
1019
+ data=broadcast.program_info.encode('utf-8'),
1020
+ )
1021
+ )
1022
+
1023
+ if broadcast.public:
1024
+ # Infer features from sources
1025
+ features = pbp.PublicBroadcastAnnouncement.Features(0)
1026
+ if broadcast.broadcast_code is not None:
1027
+ features |= pbp.PublicBroadcastAnnouncement.Features.ENCRYPTED
1028
+ for audio_source in broadcast.audio_sources:
1029
+ if audio_source.pcm_format.sample_rate == 48000:
1030
+ features |= (
1031
+ pbp.PublicBroadcastAnnouncement.Features.HIGH_QUALITY_CONFIGURATION
1032
+ )
1033
+ else:
1034
+ features |= (
1035
+ pbp.PublicBroadcastAnnouncement.Features.STANDARD_QUALITY_CONFIGURATION
1036
+ )
1037
+
1038
+ public_broadcast_announcement = pbp.PublicBroadcastAnnouncement(
1039
+ features=features, metadata=metadata
1040
+ )
1041
+ else:
1042
+ public_broadcast_announcement = None
1043
+
1044
+ broadcast_audio_announcement = bap.BroadcastAudioAnnouncement(
1045
+ broadcast.broadcast_id
1046
+ )
1047
+
1048
+ basic_audio_announcement = bap.BasicAudioAnnouncement(
1049
+ presentation_delay=40000,
1050
+ subgroups=[
1051
+ bap.BasicAudioAnnouncement.Subgroup(
1052
+ codec_id=hci.CodingFormat(codec_id=hci.CodecID.LC3),
1053
+ codec_specific_configuration=bap.CodecSpecificConfiguration(
1054
+ sampling_frequency=bap.SamplingFrequency.from_hz(
1055
+ audio_source.pcm_format.sample_rate
845
1056
  ),
1057
+ frame_duration=bap.FrameDuration.DURATION_10000_US,
1058
+ octets_per_codec_frame=audio_source.lc3_frame_size,
846
1059
  ),
847
- bap.BasicAudioAnnouncement.BIS(
848
- index=2,
849
- codec_specific_configuration=bap.CodecSpecificConfiguration(
850
- audio_channel_allocation=bap.AudioLocation.FRONT_RIGHT
851
- ),
1060
+ metadata=le_audio.Metadata(),
1061
+ bis=(
1062
+ [
1063
+ bap.BasicAudioAnnouncement.BIS(
1064
+ index=1,
1065
+ codec_specific_configuration=bap.CodecSpecificConfiguration(
1066
+ audio_channel_allocation=bap.AudioLocation.FRONT_LEFT
1067
+ ),
1068
+ ),
1069
+ bap.BasicAudioAnnouncement.BIS(
1070
+ index=2,
1071
+ codec_specific_configuration=bap.CodecSpecificConfiguration(
1072
+ audio_channel_allocation=bap.AudioLocation.FRONT_RIGHT
1073
+ ),
1074
+ ),
1075
+ ]
1076
+ if audio_source.pcm_format.channels == 2
1077
+ else [
1078
+ bap.BasicAudioAnnouncement.BIS(
1079
+ index=1,
1080
+ codec_specific_configuration=bap.CodecSpecificConfiguration(
1081
+ audio_channel_allocation=bap.AudioLocation.FRONT_LEFT
1082
+ ),
1083
+ ),
1084
+ ]
852
1085
  ),
853
- ],
854
- )
855
- ],
856
- )
857
- broadcast_audio_announcement = bap.BroadcastAudioAnnouncement(broadcast_id)
858
-
859
- advertising_data_types: list[core.DataType] = [
860
- data_types.BroadcastName(broadcast_name)
861
- ]
862
- if manufacturer_data is not None:
863
- advertising_data_types.append(
864
- data_types.ManufacturerSpecificData(*manufacturer_data)
1086
+ )
1087
+ for audio_source in broadcast.audio_sources
1088
+ ],
865
1089
  )
866
1090
 
867
- advertising_set = await device.create_advertising_set(
868
- advertising_parameters=bumble.device.AdvertisingParameters(
869
- advertising_event_properties=bumble.device.AdvertisingEventProperties(
870
- is_connectable=False
1091
+ advertising_data_types: list[core.DataType] = [
1092
+ data_types.CompleteLocalName(AURACAST_DEFAULT_DEVICE_NAME),
1093
+ data_types.Appearance(
1094
+ core.Appearance.Category.AUDIO_SOURCE,
1095
+ core.Appearance.AudioSourceSubcategory.BROADCASTING_DEVICE,
871
1096
  ),
872
- primary_advertising_interval_min=100,
873
- primary_advertising_interval_max=200,
874
- ),
875
- advertising_data=(
876
- broadcast_audio_announcement.get_advertising_data()
877
- + bytes(core.AdvertisingData(advertising_data_types))
878
- ),
879
- periodic_advertising_parameters=bumble.device.PeriodicAdvertisingParameters(
880
- periodic_advertising_interval_min=80,
881
- periodic_advertising_interval_max=160,
882
- ),
883
- periodic_advertising_data=basic_audio_announcement.get_advertising_data(),
884
- auto_restart=True,
885
- auto_start=True,
886
- )
1097
+ data_types.BroadcastName(broadcast.broadcast_name),
1098
+ ]
887
1099
 
888
- print('Start Periodic Advertising')
889
- await advertising_set.start_periodic()
1100
+ if broadcast.manufacturer_data is not None:
1101
+ advertising_data_types.append(
1102
+ data_types.ManufacturerSpecificData(*broadcast.manufacturer_data)
1103
+ )
890
1104
 
891
- async with contextlib.AsyncExitStack() as stack:
892
- audio_input = await audio_io.create_audio_input(input, input_format)
893
- pcm_format = await audio_input.open()
894
- stack.push_async_callback(audio_input.aclose)
895
- if pcm_format.channels != 2:
896
- print("Only 2 channels PCM configurations are supported")
897
- return
898
- if pcm_format.sample_type == audio_io.PcmFormat.SampleType.INT16:
899
- pcm_bit_depth = 16
900
- elif pcm_format.sample_type == audio_io.PcmFormat.SampleType.FLOAT32:
901
- pcm_bit_depth = None
902
- else:
903
- print("Only INT16 and FLOAT32 sample types are supported")
904
- return
1105
+ advertising_data = bytes(core.AdvertisingData(advertising_data_types))
1106
+ if public_broadcast_announcement:
1107
+ advertising_data += bytes(
1108
+ public_broadcast_announcement.get_advertising_data()
1109
+ )
1110
+ if broadcast_audio_announcement:
1111
+ advertising_data += broadcast_audio_announcement.get_advertising_data()
905
1112
 
906
- encoder = lc3.Encoder(
907
- frame_duration_us=AURACAST_DEFAULT_FRAME_DURATION,
908
- sample_rate_hz=AURACAST_DEFAULT_SAMPLE_RATE,
909
- num_channels=pcm_format.channels,
910
- input_sample_rate_hz=pcm_format.sample_rate,
911
- )
912
- lc3_frame_samples = encoder.get_frame_samples()
913
- lc3_frame_size = encoder.get_frame_bytes(bitrate)
1113
+ print('Starting Periodic Advertising:')
1114
+ print(f" Extended Advertising data size: {len(advertising_data)}")
914
1115
  print(
915
- f'Encoding with {lc3_frame_samples} '
916
- f'PCM samples per {lc3_frame_size} byte frame'
1116
+ f" Periodic Advertising data size: {len(basic_audio_announcement.get_advertising_data())}"
917
1117
  )
1118
+ advertising_set = await device.create_advertising_set(
1119
+ advertising_parameters=bumble.device.AdvertisingParameters(
1120
+ advertising_event_properties=bumble.device.AdvertisingEventProperties(
1121
+ is_connectable=False
1122
+ ),
1123
+ primary_advertising_interval_min=100,
1124
+ primary_advertising_interval_max=1000,
1125
+ advertising_sid=broadcast_index,
1126
+ ),
1127
+ advertising_data=advertising_data,
1128
+ periodic_advertising_parameters=bumble.device.PeriodicAdvertisingParameters(
1129
+ periodic_advertising_interval_min=100,
1130
+ periodic_advertising_interval_max=1000,
1131
+ ),
1132
+ periodic_advertising_data=basic_audio_announcement.get_advertising_data(),
1133
+ auto_restart=True,
1134
+ auto_start=True,
1135
+ )
1136
+ await advertising_set.start_periodic()
918
1137
 
919
- print('Setup BIG')
1138
+ print('Setting up BIG')
920
1139
  big = await device.create_big(
921
1140
  advertising_set,
922
1141
  parameters=bumble.device.BigParameters(
923
- num_bis=pcm_format.channels,
1142
+ num_bis=channel_count,
924
1143
  sdu_interval=AURACAST_DEFAULT_FRAME_DURATION,
925
- max_sdu=lc3_frame_size,
1144
+ max_sdu=max_lc3_frame_size,
926
1145
  max_transport_latency=65,
927
1146
  rtn=4,
928
1147
  broadcast_code=(
929
- broadcast_code_bytes(broadcast_code) if broadcast_code else None
1148
+ broadcast_code_bytes(broadcast.broadcast_code)
1149
+ if broadcast.broadcast_code
1150
+ else None
930
1151
  ),
931
1152
  ),
932
1153
  )
933
- for bis_link in big.bis_links:
1154
+
1155
+ for i, bis_link in enumerate(big.bis_links):
934
1156
  print(f'Setup ISO for BIS {bis_link.handle}')
935
1157
  await bis_link.setup_data_path(
936
1158
  direction=bis_link.Direction.HOST_TO_CONTROLLER
937
1159
  )
1160
+ iso_queue = bumble.device.IsoPacketStream(bis_link, 64)
1161
+ iso_queue.data_packet_queue.on('flow', on_flow)
1162
+ broadcast.iso_queues.append(iso_queue)
1163
+
1164
+ print('Transmitting audio from source(s)')
1165
+ while True:
1166
+ for broadcast in broadcasts:
1167
+ iso_queue_index = 0
1168
+ for audio_source in broadcast.audio_sources:
1169
+ pcm_frame = await anext(audio_source.audio_frames)
1170
+ lc3_frame = audio_source.lc3_encoder.encode(
1171
+ pcm_frame,
1172
+ num_bytes=audio_source.pcm_format.channels
1173
+ * audio_source.lc3_frame_size,
1174
+ bit_depth=audio_source.pcm_bit_depth,
1175
+ )
938
1176
 
939
- iso_queues = [
940
- bumble.device.IsoPacketStream(bis_link, 64)
941
- for bis_link in big.bis_links
942
- ]
943
-
944
- def on_flow():
945
- data_packet_queue = iso_queues[0].data_packet_queue
946
- print(
947
- f'\rPACKETS: pending={data_packet_queue.pending}, '
948
- f'queued={data_packet_queue.queued}, '
949
- f'completed={data_packet_queue.completed}',
950
- end='',
951
- )
952
-
953
- iso_queues[0].data_packet_queue.on('flow', on_flow)
954
-
955
- frame_count = 0
956
- async for pcm_frame in audio_input.frames(lc3_frame_samples):
957
- lc3_frame = encoder.encode(
958
- pcm_frame, num_bytes=2 * lc3_frame_size, bit_depth=pcm_bit_depth
959
- )
960
-
961
- mid = len(lc3_frame) // 2
962
- await iso_queues[0].write(lc3_frame[:mid])
963
- await iso_queues[1].write(lc3_frame[mid:])
1177
+ for lc3_chunk_start in range(
1178
+ 0, len(lc3_frame), audio_source.lc3_frame_size
1179
+ ):
1180
+ await broadcast.iso_queues[iso_queue_index].write(
1181
+ lc3_frame[
1182
+ lc3_chunk_start : lc3_chunk_start
1183
+ + audio_source.lc3_frame_size
1184
+ ]
1185
+ )
1186
+ iso_queue_index += 1
1187
+ finally:
1188
+ for audio_input in audio_inputs:
1189
+ await audio_input.aclose()
964
1190
 
965
- frame_count += 1
966
1191
 
1192
+ def run_async(
1193
+ async_command: Callable[..., Awaitable[Any]],
1194
+ transport: str,
1195
+ *args,
1196
+ ) -> None:
1197
+ async def run_with_transport():
1198
+ async with create_device(transport) as device:
1199
+ await async_command(device, *args)
967
1200
 
968
- def run_async(async_command: Coroutine) -> None:
969
1201
  try:
970
- asyncio.run(async_command)
1202
+ asyncio.run(run_with_transport())
971
1203
  except core.ProtocolError as error:
972
1204
  if error.error_namespace == 'att' and error.error_code in list(
973
1205
  bass.ApplicationError
@@ -1005,7 +1237,7 @@ def auracast(ctx):
1005
1237
  @click.pass_context
1006
1238
  def scan(ctx, filter_duplicates, sync_timeout, transport):
1007
1239
  """Scan for public broadcasts"""
1008
- run_async(run_scan(filter_duplicates, sync_timeout, transport))
1240
+ run_async(run_scan, transport, filter_duplicates, sync_timeout)
1009
1241
 
1010
1242
 
1011
1243
  @auracast.command('assist')
@@ -1032,7 +1264,7 @@ def scan(ctx, filter_duplicates, sync_timeout, transport):
1032
1264
  @click.pass_context
1033
1265
  def assist(ctx, broadcast_name, source_id, command, transport, address):
1034
1266
  """Scan for broadcasts on behalf of an audio server"""
1035
- run_async(run_assist(broadcast_name, source_id, command, transport, address))
1267
+ run_async(run_assist, transport, broadcast_name, source_id, command, address)
1036
1268
 
1037
1269
 
1038
1270
  @auracast.command('pair')
@@ -1041,7 +1273,7 @@ def assist(ctx, broadcast_name, source_id, command, transport, address):
1041
1273
  @click.pass_context
1042
1274
  def pair(ctx, transport, address):
1043
1275
  """Pair with an audio server"""
1044
- run_async(run_pair(transport, address))
1276
+ run_async(run_pair, transport, address)
1045
1277
 
1046
1278
 
1047
1279
  @auracast.command('receive')
@@ -1096,22 +1328,29 @@ def receive(
1096
1328
  ):
1097
1329
  """Receive a broadcast source"""
1098
1330
  run_async(
1099
- run_receive(
1100
- transport,
1101
- broadcast_id,
1102
- output,
1103
- broadcast_code,
1104
- sync_timeout,
1105
- subgroup,
1106
- )
1331
+ run_receive,
1332
+ transport,
1333
+ broadcast_id,
1334
+ output,
1335
+ broadcast_code,
1336
+ sync_timeout,
1337
+ subgroup,
1107
1338
  )
1108
1339
 
1109
1340
 
1110
1341
  @auracast.command('transmit')
1111
1342
  @click.argument('transport')
1343
+ @click.option(
1344
+ '--broadcast-list',
1345
+ metavar='BROADCAST_LIST',
1346
+ help=(
1347
+ 'Filename of a TOML broadcast list with specification(s) for one or more '
1348
+ 'broadcast sources. When used, single-source options, including --input, '
1349
+ '--input-format and others, are ignored.'
1350
+ ),
1351
+ )
1112
1352
  @click.option(
1113
1353
  '--input',
1114
- required=True,
1115
1354
  help=(
1116
1355
  "Audio input. "
1117
1356
  "'device' -> use the host's default sound input device, "
@@ -1140,18 +1379,18 @@ def receive(
1140
1379
  '--broadcast-id',
1141
1380
  metavar='BROADCAST_ID',
1142
1381
  type=int,
1143
- default=123456,
1382
+ default=AURACAST_DEFAULT_BROADCAST_ID,
1144
1383
  help='Broadcast ID',
1145
1384
  )
1146
1385
  @click.option(
1147
1386
  '--broadcast-code',
1148
1387
  metavar='BROADCAST_CODE',
1149
- help='Broadcast encryption code in hex format',
1388
+ help='Broadcast encryption code in hex format or as a string',
1150
1389
  )
1151
1390
  @click.option(
1152
1391
  '--broadcast-name',
1153
1392
  metavar='BROADCAST_NAME',
1154
- default='Bumble Auracast',
1393
+ default=AURACAST_DEFAULT_BROADCAST_NAME,
1155
1394
  help='Broadcast name',
1156
1395
  )
1157
1396
  @click.option(
@@ -1169,39 +1408,73 @@ def receive(
1169
1408
  def transmit(
1170
1409
  ctx,
1171
1410
  transport,
1411
+ broadcast_list,
1412
+ input,
1413
+ input_format,
1172
1414
  broadcast_id,
1173
1415
  broadcast_code,
1174
- manufacturer_data,
1175
1416
  broadcast_name,
1176
1417
  bitrate,
1177
- input,
1178
- input_format,
1418
+ manufacturer_data,
1179
1419
  ):
1180
1420
  """Transmit a broadcast source"""
1181
- if manufacturer_data:
1182
- vendor_id_str, data_hex = manufacturer_data.split(':')
1183
- vendor_id = int(vendor_id_str)
1184
- data = bytes.fromhex(data_hex)
1185
- manufacturer_data_tuple = (vendor_id, data)
1421
+ if broadcast_list:
1422
+ broadcasts = parse_broadcast_list(broadcast_list)
1423
+ if not broadcasts:
1424
+ print(color('!!! Broadcast list is empty or invalid', 'red'))
1425
+ return
1426
+ for broadcast in broadcasts:
1427
+ if not broadcast.sources:
1428
+ print(
1429
+ color(
1430
+ f'!!! Broadcast "{broadcast.broadcast_name}" has no sources',
1431
+ 'red',
1432
+ )
1433
+ )
1434
+ return
1186
1435
  else:
1187
- manufacturer_data_tuple = None
1436
+ if input is None and broadcast_list is None:
1437
+ print(
1438
+ color('!!! --input is required if --broadcast-list is not used', 'red')
1439
+ )
1440
+ return
1441
+
1442
+ if (
1443
+ input == 'device' or input.startswith('device:')
1444
+ ) and input_format == 'auto':
1445
+ # Use a default format for device inputs
1446
+ input_format = 'int16le,48000,1'
1447
+
1448
+ if manufacturer_data:
1449
+ vendor_id_str, data_hex = manufacturer_data.split(':')
1450
+ vendor_id = int(vendor_id_str)
1451
+ data = bytes.fromhex(data_hex)
1452
+ manufacturer_data_tuple = (vendor_id, data)
1453
+ else:
1454
+ manufacturer_data_tuple = None
1455
+
1456
+ broadcasts = [
1457
+ Broadcast(
1458
+ sources=[
1459
+ BroadcastSource(
1460
+ input=input,
1461
+ input_format=input_format,
1462
+ bitrate=bitrate,
1463
+ )
1464
+ ],
1465
+ public=True,
1466
+ broadcast_id=broadcast_id,
1467
+ broadcast_name=broadcast_name,
1468
+ broadcast_code=broadcast_code,
1469
+ manufacturer_data=manufacturer_data_tuple,
1470
+ language=AURACAST_DEFAULT_LANGUAGE,
1471
+ program_info=AURACAST_DEFAULT_PROGRAM_INFO,
1472
+ )
1473
+ ]
1188
1474
 
1189
- if (input == 'device' or input.startswith('device:')) and input_format == 'auto':
1190
- # Use a default format for device inputs
1191
- input_format = 'int16le,48000,1'
1475
+ assign_broadcast_ids(broadcasts)
1192
1476
 
1193
- run_async(
1194
- run_transmit(
1195
- transport=transport,
1196
- broadcast_id=broadcast_id,
1197
- broadcast_code=broadcast_code,
1198
- broadcast_name=broadcast_name,
1199
- bitrate=bitrate,
1200
- manufacturer_data=manufacturer_data_tuple,
1201
- input=input,
1202
- input_format=input_format,
1203
- )
1204
- )
1477
+ run_async(run_transmit, transport, broadcasts)
1205
1478
 
1206
1479
 
1207
1480
  def main():