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