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