bumble 0.0.155__py3-none-any.whl → 0.0.156__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/a2dp.py +1 -0
- bumble/apps/speaker/__init__.py +0 -0
- bumble/apps/speaker/logo.svg +42 -0
- bumble/apps/speaker/speaker.css +76 -0
- bumble/apps/speaker/speaker.html +34 -0
- bumble/apps/speaker/speaker.js +315 -0
- bumble/apps/speaker/speaker.py +747 -0
- bumble/avdtp.py +50 -31
- bumble/codecs.py +381 -0
- bumble/device.py +7 -3
- bumble/hci.py +13 -9
- bumble/host.py +7 -1
- {bumble-0.0.155.dist-info → bumble-0.0.156.dist-info}/METADATA +5 -4
- {bumble-0.0.155.dist-info → bumble-0.0.156.dist-info}/RECORD +19 -12
- {bumble-0.0.155.dist-info → bumble-0.0.156.dist-info}/entry_points.txt +1 -0
- {bumble-0.0.155.dist-info → bumble-0.0.156.dist-info}/LICENSE +0 -0
- {bumble-0.0.155.dist-info → bumble-0.0.156.dist-info}/WHEEL +0 -0
- {bumble-0.0.155.dist-info → bumble-0.0.156.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,747 @@
|
|
|
1
|
+
# Copyright 2021-2023 Google LLC
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# https://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
# -----------------------------------------------------------------------------
|
|
16
|
+
# Imports
|
|
17
|
+
# -----------------------------------------------------------------------------
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
import asyncio
|
|
20
|
+
import asyncio.subprocess
|
|
21
|
+
from importlib import resources
|
|
22
|
+
import enum
|
|
23
|
+
import json
|
|
24
|
+
import os
|
|
25
|
+
import logging
|
|
26
|
+
import pathlib
|
|
27
|
+
import subprocess
|
|
28
|
+
from typing import Dict, List, Optional
|
|
29
|
+
import weakref
|
|
30
|
+
|
|
31
|
+
import click
|
|
32
|
+
import aiohttp
|
|
33
|
+
from aiohttp import web
|
|
34
|
+
|
|
35
|
+
import bumble
|
|
36
|
+
from bumble.colors import color
|
|
37
|
+
from bumble.core import BT_BR_EDR_TRANSPORT, CommandTimeoutError
|
|
38
|
+
from bumble.device import Connection, Device, DeviceConfiguration
|
|
39
|
+
from bumble.hci import HCI_StatusError
|
|
40
|
+
from bumble.pairing import PairingConfig
|
|
41
|
+
from bumble.sdp import ServiceAttribute
|
|
42
|
+
from bumble.transport import open_transport
|
|
43
|
+
from bumble.avdtp import (
|
|
44
|
+
AVDTP_AUDIO_MEDIA_TYPE,
|
|
45
|
+
Listener,
|
|
46
|
+
MediaCodecCapabilities,
|
|
47
|
+
MediaPacket,
|
|
48
|
+
Protocol,
|
|
49
|
+
)
|
|
50
|
+
from bumble.a2dp import (
|
|
51
|
+
MPEG_2_AAC_LC_OBJECT_TYPE,
|
|
52
|
+
make_audio_sink_service_sdp_records,
|
|
53
|
+
A2DP_SBC_CODEC_TYPE,
|
|
54
|
+
A2DP_MPEG_2_4_AAC_CODEC_TYPE,
|
|
55
|
+
SBC_MONO_CHANNEL_MODE,
|
|
56
|
+
SBC_DUAL_CHANNEL_MODE,
|
|
57
|
+
SBC_SNR_ALLOCATION_METHOD,
|
|
58
|
+
SBC_LOUDNESS_ALLOCATION_METHOD,
|
|
59
|
+
SBC_STEREO_CHANNEL_MODE,
|
|
60
|
+
SBC_JOINT_STEREO_CHANNEL_MODE,
|
|
61
|
+
SbcMediaCodecInformation,
|
|
62
|
+
AacMediaCodecInformation,
|
|
63
|
+
)
|
|
64
|
+
from bumble.utils import AsyncRunner
|
|
65
|
+
from bumble.codecs import AacAudioRtpPacket
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# -----------------------------------------------------------------------------
|
|
69
|
+
# Logging
|
|
70
|
+
# -----------------------------------------------------------------------------
|
|
71
|
+
logger = logging.getLogger(__name__)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# -----------------------------------------------------------------------------
|
|
75
|
+
# Constants
|
|
76
|
+
# -----------------------------------------------------------------------------
|
|
77
|
+
DEFAULT_UI_PORT = 7654
|
|
78
|
+
|
|
79
|
+
# -----------------------------------------------------------------------------
|
|
80
|
+
class AudioExtractor:
|
|
81
|
+
@staticmethod
|
|
82
|
+
def create(codec: str):
|
|
83
|
+
if codec == 'aac':
|
|
84
|
+
return AacAudioExtractor()
|
|
85
|
+
if codec == 'sbc':
|
|
86
|
+
return SbcAudioExtractor()
|
|
87
|
+
|
|
88
|
+
def extract_audio(self, packet: MediaPacket) -> bytes:
|
|
89
|
+
raise NotImplementedError()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# -----------------------------------------------------------------------------
|
|
93
|
+
class AacAudioExtractor:
|
|
94
|
+
def extract_audio(self, packet: MediaPacket) -> bytes:
|
|
95
|
+
return AacAudioRtpPacket(packet.payload).to_adts()
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
# -----------------------------------------------------------------------------
|
|
99
|
+
class SbcAudioExtractor:
|
|
100
|
+
def extract_audio(self, packet: MediaPacket) -> bytes:
|
|
101
|
+
# header = packet.payload[0]
|
|
102
|
+
# fragmented = header >> 7
|
|
103
|
+
# start = (header >> 6) & 0x01
|
|
104
|
+
# last = (header >> 5) & 0x01
|
|
105
|
+
# number_of_frames = header & 0x0F
|
|
106
|
+
|
|
107
|
+
# TODO: support fragmented payloads
|
|
108
|
+
return packet.payload[1:]
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# -----------------------------------------------------------------------------
|
|
112
|
+
class Output:
|
|
113
|
+
async def start(self) -> None:
|
|
114
|
+
pass
|
|
115
|
+
|
|
116
|
+
async def stop(self) -> None:
|
|
117
|
+
pass
|
|
118
|
+
|
|
119
|
+
async def suspend(self) -> None:
|
|
120
|
+
pass
|
|
121
|
+
|
|
122
|
+
async def on_connection(self, connection: Connection) -> None:
|
|
123
|
+
pass
|
|
124
|
+
|
|
125
|
+
async def on_disconnection(self, reason: int) -> None:
|
|
126
|
+
pass
|
|
127
|
+
|
|
128
|
+
def on_rtp_packet(self, packet: MediaPacket) -> None:
|
|
129
|
+
pass
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
# -----------------------------------------------------------------------------
|
|
133
|
+
class FileOutput(Output):
|
|
134
|
+
filename: str
|
|
135
|
+
codec: str
|
|
136
|
+
extractor: AudioExtractor
|
|
137
|
+
|
|
138
|
+
def __init__(self, filename, codec):
|
|
139
|
+
self.filename = filename
|
|
140
|
+
self.codec = codec
|
|
141
|
+
self.file = open(filename, 'wb')
|
|
142
|
+
self.extractor = AudioExtractor.create(codec)
|
|
143
|
+
|
|
144
|
+
def on_rtp_packet(self, packet: MediaPacket) -> None:
|
|
145
|
+
self.file.write(self.extractor.extract_audio(packet))
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
# -----------------------------------------------------------------------------
|
|
149
|
+
class QueuedOutput(Output):
|
|
150
|
+
MAX_QUEUE_SIZE = 32768
|
|
151
|
+
|
|
152
|
+
packets: asyncio.Queue
|
|
153
|
+
extractor: AudioExtractor
|
|
154
|
+
packet_pump_task: Optional[asyncio.Task]
|
|
155
|
+
started: bool
|
|
156
|
+
|
|
157
|
+
def __init__(self, extractor):
|
|
158
|
+
self.extractor = extractor
|
|
159
|
+
self.packets = asyncio.Queue()
|
|
160
|
+
self.packet_pump_task = None
|
|
161
|
+
self.started = False
|
|
162
|
+
|
|
163
|
+
async def start(self):
|
|
164
|
+
if self.started:
|
|
165
|
+
return
|
|
166
|
+
|
|
167
|
+
self.packet_pump_task = asyncio.create_task(self.pump_packets())
|
|
168
|
+
|
|
169
|
+
async def pump_packets(self):
|
|
170
|
+
while True:
|
|
171
|
+
packet = await self.packets.get()
|
|
172
|
+
await self.on_audio_packet(packet)
|
|
173
|
+
|
|
174
|
+
async def on_audio_packet(self, packet: bytes) -> None:
|
|
175
|
+
pass
|
|
176
|
+
|
|
177
|
+
def on_rtp_packet(self, packet: MediaPacket) -> None:
|
|
178
|
+
if self.packets.qsize() > self.MAX_QUEUE_SIZE:
|
|
179
|
+
logger.debug("queue full, dropping")
|
|
180
|
+
return
|
|
181
|
+
|
|
182
|
+
self.packets.put_nowait(self.extractor.extract_audio(packet))
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
# -----------------------------------------------------------------------------
|
|
186
|
+
class WebSocketOutput(QueuedOutput):
|
|
187
|
+
def __init__(self, codec, send_audio, send_message):
|
|
188
|
+
super().__init__(AudioExtractor.create(codec))
|
|
189
|
+
self.send_audio = send_audio
|
|
190
|
+
self.send_message = send_message
|
|
191
|
+
|
|
192
|
+
async def on_connection(self, connection: Connection) -> None:
|
|
193
|
+
try:
|
|
194
|
+
await connection.request_remote_name()
|
|
195
|
+
except HCI_StatusError:
|
|
196
|
+
pass
|
|
197
|
+
peer_name = '' if connection.peer_name is None else connection.peer_name
|
|
198
|
+
peer_address = str(connection.peer_address).replace('/P', '')
|
|
199
|
+
await self.send_message(
|
|
200
|
+
'connection',
|
|
201
|
+
peer_address=peer_address,
|
|
202
|
+
peer_name=peer_name,
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
async def on_disconnection(self, reason) -> None:
|
|
206
|
+
await self.send_message('disconnection')
|
|
207
|
+
|
|
208
|
+
async def on_audio_packet(self, packet: bytes) -> None:
|
|
209
|
+
await self.send_audio(packet)
|
|
210
|
+
|
|
211
|
+
async def start(self):
|
|
212
|
+
await super().start()
|
|
213
|
+
await self.send_message('start')
|
|
214
|
+
|
|
215
|
+
async def stop(self):
|
|
216
|
+
await super().stop()
|
|
217
|
+
await self.send_message('stop')
|
|
218
|
+
|
|
219
|
+
async def suspend(self):
|
|
220
|
+
await super().suspend()
|
|
221
|
+
await self.send_message('suspend')
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
# -----------------------------------------------------------------------------
|
|
225
|
+
class FfplayOutput(QueuedOutput):
|
|
226
|
+
MAX_QUEUE_SIZE = 32768
|
|
227
|
+
|
|
228
|
+
subprocess: Optional[asyncio.subprocess.Process]
|
|
229
|
+
ffplay_task: Optional[asyncio.Task]
|
|
230
|
+
|
|
231
|
+
def __init__(self) -> None:
|
|
232
|
+
super().__init__(AacAudioExtractor())
|
|
233
|
+
self.subprocess = None
|
|
234
|
+
self.ffplay_task = None
|
|
235
|
+
|
|
236
|
+
async def start(self):
|
|
237
|
+
if self.started:
|
|
238
|
+
return
|
|
239
|
+
|
|
240
|
+
await super().start()
|
|
241
|
+
|
|
242
|
+
self.subprocess = await asyncio.create_subprocess_shell(
|
|
243
|
+
'ffplay -acodec aac pipe:0',
|
|
244
|
+
stdin=asyncio.subprocess.PIPE,
|
|
245
|
+
stdout=asyncio.subprocess.PIPE,
|
|
246
|
+
stderr=asyncio.subprocess.PIPE,
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
self.ffplay_task = asyncio.create_task(self.monitor_ffplay())
|
|
250
|
+
|
|
251
|
+
async def stop(self):
|
|
252
|
+
# TODO
|
|
253
|
+
pass
|
|
254
|
+
|
|
255
|
+
async def suspend(self):
|
|
256
|
+
# TODO
|
|
257
|
+
pass
|
|
258
|
+
|
|
259
|
+
async def monitor_ffplay(self):
|
|
260
|
+
async def read_stream(name, stream):
|
|
261
|
+
while True:
|
|
262
|
+
data = await stream.read()
|
|
263
|
+
logger.debug(f'{name}:', data)
|
|
264
|
+
|
|
265
|
+
await asyncio.wait(
|
|
266
|
+
[
|
|
267
|
+
asyncio.create_task(
|
|
268
|
+
read_stream('[ffplay stdout]', self.subprocess.stdout)
|
|
269
|
+
),
|
|
270
|
+
asyncio.create_task(
|
|
271
|
+
read_stream('[ffplay stderr]', self.subprocess.stderr)
|
|
272
|
+
),
|
|
273
|
+
asyncio.create_task(self.subprocess.wait()),
|
|
274
|
+
]
|
|
275
|
+
)
|
|
276
|
+
logger.debug("FFPLAY done")
|
|
277
|
+
|
|
278
|
+
async def on_audio_packet(self, packet):
|
|
279
|
+
try:
|
|
280
|
+
self.subprocess.stdin.write(packet)
|
|
281
|
+
except Exception:
|
|
282
|
+
logger.warning('!!!! exception while sending audio to ffplay pipe')
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
# -----------------------------------------------------------------------------
|
|
286
|
+
class UiServer:
|
|
287
|
+
speaker: weakref.ReferenceType[Speaker]
|
|
288
|
+
port: int
|
|
289
|
+
|
|
290
|
+
def __init__(self, speaker: Speaker, port: int) -> None:
|
|
291
|
+
self.speaker = weakref.ref(speaker)
|
|
292
|
+
self.port = port
|
|
293
|
+
self.channel_socket = None
|
|
294
|
+
|
|
295
|
+
async def start_http(self) -> None:
|
|
296
|
+
"""Start the UI HTTP server."""
|
|
297
|
+
|
|
298
|
+
app = web.Application()
|
|
299
|
+
app.add_routes(
|
|
300
|
+
[
|
|
301
|
+
web.get('/', self.get_static),
|
|
302
|
+
web.get('/speaker.html', self.get_static),
|
|
303
|
+
web.get('/speaker.js', self.get_static),
|
|
304
|
+
web.get('/speaker.css', self.get_static),
|
|
305
|
+
web.get('/logo.svg', self.get_static),
|
|
306
|
+
web.get('/channel', self.get_channel),
|
|
307
|
+
]
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
runner = web.AppRunner(app)
|
|
311
|
+
await runner.setup()
|
|
312
|
+
site = web.TCPSite(runner, 'localhost', self.port)
|
|
313
|
+
print('UI HTTP server at ' + color(f'http://127.0.0.1:{self.port}', 'green'))
|
|
314
|
+
await site.start()
|
|
315
|
+
|
|
316
|
+
async def get_static(self, request):
|
|
317
|
+
path = request.path
|
|
318
|
+
if path == '/':
|
|
319
|
+
path = '/speaker.html'
|
|
320
|
+
if path.endswith('.html'):
|
|
321
|
+
content_type = 'text/html'
|
|
322
|
+
elif path.endswith('.js'):
|
|
323
|
+
content_type = 'text/javascript'
|
|
324
|
+
elif path.endswith('.css'):
|
|
325
|
+
content_type = 'text/css'
|
|
326
|
+
elif path.endswith('.svg'):
|
|
327
|
+
content_type = 'image/svg+xml'
|
|
328
|
+
else:
|
|
329
|
+
content_type = 'text/plain'
|
|
330
|
+
text = (
|
|
331
|
+
resources.files("bumble.apps.speaker")
|
|
332
|
+
.joinpath(pathlib.Path(path).relative_to('/'))
|
|
333
|
+
.read_text(encoding="utf-8")
|
|
334
|
+
)
|
|
335
|
+
return aiohttp.web.Response(text=text, content_type=content_type)
|
|
336
|
+
|
|
337
|
+
async def get_channel(self, request):
|
|
338
|
+
ws = web.WebSocketResponse()
|
|
339
|
+
await ws.prepare(request)
|
|
340
|
+
|
|
341
|
+
# Process messages until the socket is closed.
|
|
342
|
+
self.channel_socket = ws
|
|
343
|
+
async for message in ws:
|
|
344
|
+
if message.type == aiohttp.WSMsgType.TEXT:
|
|
345
|
+
logger.debug(f'<<< received message: {message.data}')
|
|
346
|
+
await self.on_message(message.data)
|
|
347
|
+
elif message.type == aiohttp.WSMsgType.ERROR:
|
|
348
|
+
logger.debug(
|
|
349
|
+
f'channel connection closed with exception {ws.exception()}'
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
self.channel_socket = None
|
|
353
|
+
logger.debug('--- channel connection closed')
|
|
354
|
+
|
|
355
|
+
return ws
|
|
356
|
+
|
|
357
|
+
async def on_message(self, message_str: str):
|
|
358
|
+
# Parse the message as JSON
|
|
359
|
+
message = json.loads(message_str)
|
|
360
|
+
|
|
361
|
+
# Dispatch the message
|
|
362
|
+
message_type = message['type']
|
|
363
|
+
message_params = message.get('params', {})
|
|
364
|
+
handler = getattr(self, f'on_{message_type}_message')
|
|
365
|
+
if handler:
|
|
366
|
+
await handler(**message_params)
|
|
367
|
+
|
|
368
|
+
async def on_hello_message(self):
|
|
369
|
+
await self.send_message(
|
|
370
|
+
'hello',
|
|
371
|
+
bumble_version=bumble.__version__,
|
|
372
|
+
codec=self.speaker().codec,
|
|
373
|
+
streamState=self.speaker().stream_state.name,
|
|
374
|
+
)
|
|
375
|
+
if connection := self.speaker().connection:
|
|
376
|
+
await self.send_message(
|
|
377
|
+
'connection',
|
|
378
|
+
peer_address=str(connection.peer_address).replace('/P', ''),
|
|
379
|
+
peer_name=connection.peer_name,
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
async def send_message(self, message_type: str, **kwargs) -> None:
|
|
383
|
+
if self.channel_socket is None:
|
|
384
|
+
return
|
|
385
|
+
|
|
386
|
+
message = {'type': message_type, 'params': kwargs}
|
|
387
|
+
await self.channel_socket.send_json(message)
|
|
388
|
+
|
|
389
|
+
async def send_audio(self, data: bytes) -> None:
|
|
390
|
+
if self.channel_socket is None:
|
|
391
|
+
return
|
|
392
|
+
|
|
393
|
+
try:
|
|
394
|
+
await self.channel_socket.send_bytes(data)
|
|
395
|
+
except Exception as error:
|
|
396
|
+
logger.warning(f'exception while sending audio packet: {error}')
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
# -----------------------------------------------------------------------------
|
|
400
|
+
class Speaker:
|
|
401
|
+
class StreamState(enum.Enum):
|
|
402
|
+
IDLE = 0
|
|
403
|
+
STOPPED = 1
|
|
404
|
+
STARTED = 2
|
|
405
|
+
SUSPENDED = 3
|
|
406
|
+
|
|
407
|
+
def __init__(self, device_config, transport, codec, discover, outputs, ui_port):
|
|
408
|
+
self.device_config = device_config
|
|
409
|
+
self.transport = transport
|
|
410
|
+
self.codec = codec
|
|
411
|
+
self.discover = discover
|
|
412
|
+
self.ui_port = ui_port
|
|
413
|
+
self.device = None
|
|
414
|
+
self.connection = None
|
|
415
|
+
self.listener = None
|
|
416
|
+
self.packets_received = 0
|
|
417
|
+
self.bytes_received = 0
|
|
418
|
+
self.stream_state = Speaker.StreamState.IDLE
|
|
419
|
+
self.outputs = []
|
|
420
|
+
for output in outputs:
|
|
421
|
+
if output == '@ffplay':
|
|
422
|
+
self.outputs.append(FfplayOutput())
|
|
423
|
+
continue
|
|
424
|
+
|
|
425
|
+
# Default to FileOutput
|
|
426
|
+
self.outputs.append(FileOutput(output, codec))
|
|
427
|
+
|
|
428
|
+
# Create an HTTP server for the UI
|
|
429
|
+
self.ui_server = UiServer(speaker=self, port=ui_port)
|
|
430
|
+
|
|
431
|
+
def sdp_records(self) -> Dict[int, List[ServiceAttribute]]:
|
|
432
|
+
service_record_handle = 0x00010001
|
|
433
|
+
return {
|
|
434
|
+
service_record_handle: make_audio_sink_service_sdp_records(
|
|
435
|
+
service_record_handle
|
|
436
|
+
)
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
def codec_capabilities(self) -> MediaCodecCapabilities:
|
|
440
|
+
if self.codec == 'aac':
|
|
441
|
+
return self.aac_codec_capabilities()
|
|
442
|
+
|
|
443
|
+
if self.codec == 'sbc':
|
|
444
|
+
return self.sbc_codec_capabilities()
|
|
445
|
+
|
|
446
|
+
raise RuntimeError('unsupported codec')
|
|
447
|
+
|
|
448
|
+
def aac_codec_capabilities(self) -> MediaCodecCapabilities:
|
|
449
|
+
return MediaCodecCapabilities(
|
|
450
|
+
media_type=AVDTP_AUDIO_MEDIA_TYPE,
|
|
451
|
+
media_codec_type=A2DP_MPEG_2_4_AAC_CODEC_TYPE,
|
|
452
|
+
media_codec_information=AacMediaCodecInformation.from_lists(
|
|
453
|
+
object_types=[MPEG_2_AAC_LC_OBJECT_TYPE],
|
|
454
|
+
sampling_frequencies=[48000, 44100],
|
|
455
|
+
channels=[1, 2],
|
|
456
|
+
vbr=1,
|
|
457
|
+
bitrate=256000,
|
|
458
|
+
),
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
def sbc_codec_capabilities(self) -> MediaCodecCapabilities:
|
|
462
|
+
return MediaCodecCapabilities(
|
|
463
|
+
media_type=AVDTP_AUDIO_MEDIA_TYPE,
|
|
464
|
+
media_codec_type=A2DP_SBC_CODEC_TYPE,
|
|
465
|
+
media_codec_information=SbcMediaCodecInformation.from_lists(
|
|
466
|
+
sampling_frequencies=[48000, 44100, 32000, 16000],
|
|
467
|
+
channel_modes=[
|
|
468
|
+
SBC_MONO_CHANNEL_MODE,
|
|
469
|
+
SBC_DUAL_CHANNEL_MODE,
|
|
470
|
+
SBC_STEREO_CHANNEL_MODE,
|
|
471
|
+
SBC_JOINT_STEREO_CHANNEL_MODE,
|
|
472
|
+
],
|
|
473
|
+
block_lengths=[4, 8, 12, 16],
|
|
474
|
+
subbands=[4, 8],
|
|
475
|
+
allocation_methods=[
|
|
476
|
+
SBC_LOUDNESS_ALLOCATION_METHOD,
|
|
477
|
+
SBC_SNR_ALLOCATION_METHOD,
|
|
478
|
+
],
|
|
479
|
+
minimum_bitpool_value=2,
|
|
480
|
+
maximum_bitpool_value=53,
|
|
481
|
+
),
|
|
482
|
+
)
|
|
483
|
+
|
|
484
|
+
async def dispatch_to_outputs(self, function):
|
|
485
|
+
for output in self.outputs:
|
|
486
|
+
await function(output)
|
|
487
|
+
|
|
488
|
+
def on_bluetooth_connection(self, connection):
|
|
489
|
+
print(f'Connection: {connection}')
|
|
490
|
+
self.connection = connection
|
|
491
|
+
connection.on('disconnection', self.on_bluetooth_disconnection)
|
|
492
|
+
AsyncRunner.spawn(
|
|
493
|
+
self.dispatch_to_outputs(lambda output: output.on_connection(connection))
|
|
494
|
+
)
|
|
495
|
+
|
|
496
|
+
def on_bluetooth_disconnection(self, reason):
|
|
497
|
+
print(f'Disconnection ({reason})')
|
|
498
|
+
self.connection = None
|
|
499
|
+
AsyncRunner.spawn(self.advertise())
|
|
500
|
+
AsyncRunner.spawn(
|
|
501
|
+
self.dispatch_to_outputs(lambda output: output.on_disconnection(reason))
|
|
502
|
+
)
|
|
503
|
+
|
|
504
|
+
def on_avdtp_connection(self, protocol):
|
|
505
|
+
print('Audio Stream Open')
|
|
506
|
+
|
|
507
|
+
# Add a sink endpoint to the server
|
|
508
|
+
sink = protocol.add_sink(self.codec_capabilities())
|
|
509
|
+
sink.on('start', self.on_sink_start)
|
|
510
|
+
sink.on('stop', self.on_sink_stop)
|
|
511
|
+
sink.on('suspend', self.on_sink_suspend)
|
|
512
|
+
sink.on('configuration', lambda: self.on_sink_configuration(sink.configuration))
|
|
513
|
+
sink.on('rtp_packet', self.on_rtp_packet)
|
|
514
|
+
sink.on('rtp_channel_open', self.on_rtp_channel_open)
|
|
515
|
+
sink.on('rtp_channel_close', self.on_rtp_channel_close)
|
|
516
|
+
|
|
517
|
+
# Listen for close events
|
|
518
|
+
protocol.on('close', self.on_avdtp_close)
|
|
519
|
+
|
|
520
|
+
# Discover all endpoints on the remote device is requested
|
|
521
|
+
if self.discover:
|
|
522
|
+
AsyncRunner.spawn(self.discover_remote_endpoints(protocol))
|
|
523
|
+
|
|
524
|
+
def on_avdtp_close(self):
|
|
525
|
+
print("Audio Stream Closed")
|
|
526
|
+
|
|
527
|
+
def on_sink_start(self):
|
|
528
|
+
print("Sink Started\u001b[0K")
|
|
529
|
+
self.stream_state = self.StreamState.STARTED
|
|
530
|
+
AsyncRunner.spawn(self.dispatch_to_outputs(lambda output: output.start()))
|
|
531
|
+
|
|
532
|
+
def on_sink_stop(self):
|
|
533
|
+
print("Sink Stopped\u001b[0K")
|
|
534
|
+
self.stream_state = self.StreamState.STOPPED
|
|
535
|
+
AsyncRunner.spawn(self.dispatch_to_outputs(lambda output: output.stop()))
|
|
536
|
+
|
|
537
|
+
def on_sink_suspend(self):
|
|
538
|
+
print("Sink Suspended\u001b[0K")
|
|
539
|
+
self.stream_state = self.StreamState.SUSPENDED
|
|
540
|
+
AsyncRunner.spawn(self.dispatch_to_outputs(lambda output: output.suspend()))
|
|
541
|
+
|
|
542
|
+
def on_sink_configuration(self, config):
|
|
543
|
+
print("Sink Configuration:")
|
|
544
|
+
print('\n'.join([" " + str(capability) for capability in config]))
|
|
545
|
+
|
|
546
|
+
def on_rtp_channel_open(self):
|
|
547
|
+
print("RTP Channel Open")
|
|
548
|
+
|
|
549
|
+
def on_rtp_channel_close(self):
|
|
550
|
+
print("RTP Channel Closed")
|
|
551
|
+
self.stream_state = self.StreamState.IDLE
|
|
552
|
+
|
|
553
|
+
def on_rtp_packet(self, packet):
|
|
554
|
+
self.packets_received += 1
|
|
555
|
+
self.bytes_received += len(packet.payload)
|
|
556
|
+
print(
|
|
557
|
+
f'[{self.bytes_received} bytes in {self.packets_received} packets] {packet}',
|
|
558
|
+
end='\r',
|
|
559
|
+
)
|
|
560
|
+
|
|
561
|
+
for output in self.outputs:
|
|
562
|
+
output.on_rtp_packet(packet)
|
|
563
|
+
|
|
564
|
+
async def advertise(self):
|
|
565
|
+
await self.device.set_discoverable(True)
|
|
566
|
+
await self.device.set_connectable(True)
|
|
567
|
+
|
|
568
|
+
async def connect(self, address):
|
|
569
|
+
# Connect to the source
|
|
570
|
+
print(f'=== Connecting to {address}...')
|
|
571
|
+
connection = await self.device.connect(address, transport=BT_BR_EDR_TRANSPORT)
|
|
572
|
+
print(f'=== Connected to {connection.peer_address}')
|
|
573
|
+
|
|
574
|
+
# Request authentication
|
|
575
|
+
print('*** Authenticating...')
|
|
576
|
+
await connection.authenticate()
|
|
577
|
+
print('*** Authenticated')
|
|
578
|
+
|
|
579
|
+
# Enable encryption
|
|
580
|
+
print('*** Enabling encryption...')
|
|
581
|
+
await connection.encrypt()
|
|
582
|
+
print('*** Encryption on')
|
|
583
|
+
|
|
584
|
+
protocol = await Protocol.connect(connection)
|
|
585
|
+
self.listener.set_server(connection, protocol)
|
|
586
|
+
self.on_avdtp_connection(protocol)
|
|
587
|
+
|
|
588
|
+
async def discover_remote_endpoints(self, protocol):
|
|
589
|
+
endpoints = await protocol.discover_remote_endpoints()
|
|
590
|
+
print(f'@@@ Found {len(endpoints)} endpoints')
|
|
591
|
+
for endpoint in endpoints:
|
|
592
|
+
print('@@@', endpoint)
|
|
593
|
+
|
|
594
|
+
async def run(self, connect_address):
|
|
595
|
+
await self.ui_server.start_http()
|
|
596
|
+
self.outputs.append(
|
|
597
|
+
WebSocketOutput(
|
|
598
|
+
self.codec, self.ui_server.send_audio, self.ui_server.send_message
|
|
599
|
+
)
|
|
600
|
+
)
|
|
601
|
+
|
|
602
|
+
async with await open_transport(self.transport) as (hci_source, hci_sink):
|
|
603
|
+
# Create a device
|
|
604
|
+
device_config = DeviceConfiguration()
|
|
605
|
+
if self.device_config:
|
|
606
|
+
device_config.load_from_file(self.device_config)
|
|
607
|
+
else:
|
|
608
|
+
device_config.name = "Bumble Speaker"
|
|
609
|
+
device_config.class_of_device = 0x240414
|
|
610
|
+
device_config.keystore = "JsonKeyStore"
|
|
611
|
+
|
|
612
|
+
device_config.classic_enabled = True
|
|
613
|
+
device_config.le_enabled = False
|
|
614
|
+
self.device = Device.from_config_with_hci(
|
|
615
|
+
device_config, hci_source, hci_sink
|
|
616
|
+
)
|
|
617
|
+
|
|
618
|
+
# Setup the SDP to expose the sink service
|
|
619
|
+
self.device.sdp_service_records = self.sdp_records()
|
|
620
|
+
|
|
621
|
+
# Don't require MITM when pairing.
|
|
622
|
+
self.device.pairing_config_factory = lambda connection: PairingConfig(
|
|
623
|
+
mitm=False
|
|
624
|
+
)
|
|
625
|
+
|
|
626
|
+
# Start the controller
|
|
627
|
+
await self.device.power_on()
|
|
628
|
+
|
|
629
|
+
# Print some of the config/properties
|
|
630
|
+
print("Speaker Name:", color(device_config.name, 'yellow'))
|
|
631
|
+
print(
|
|
632
|
+
"Speaker Bluetooth Address:",
|
|
633
|
+
color(
|
|
634
|
+
self.device.public_address.to_string(with_type_qualifier=False),
|
|
635
|
+
'yellow',
|
|
636
|
+
),
|
|
637
|
+
)
|
|
638
|
+
|
|
639
|
+
# Listen for Bluetooth connections
|
|
640
|
+
self.device.on('connection', self.on_bluetooth_connection)
|
|
641
|
+
|
|
642
|
+
# Create a listener to wait for AVDTP connections
|
|
643
|
+
self.listener = Listener(Listener.create_registrar(self.device))
|
|
644
|
+
self.listener.on('connection', self.on_avdtp_connection)
|
|
645
|
+
|
|
646
|
+
print(f'Speaker ready to play, codec={color(self.codec, "cyan")}')
|
|
647
|
+
|
|
648
|
+
if connect_address:
|
|
649
|
+
# Connect to the source
|
|
650
|
+
try:
|
|
651
|
+
await self.connect(connect_address)
|
|
652
|
+
except CommandTimeoutError:
|
|
653
|
+
print(color("Connection timed out", "red"))
|
|
654
|
+
return
|
|
655
|
+
else:
|
|
656
|
+
# Start being discoverable and connectable
|
|
657
|
+
print("Waiting for connection...")
|
|
658
|
+
await self.advertise()
|
|
659
|
+
|
|
660
|
+
await hci_source.wait_for_termination()
|
|
661
|
+
|
|
662
|
+
for output in self.outputs:
|
|
663
|
+
await output.stop()
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
# -----------------------------------------------------------------------------
|
|
667
|
+
@click.group()
|
|
668
|
+
@click.pass_context
|
|
669
|
+
def speaker_cli(ctx, device_config):
|
|
670
|
+
ctx.ensure_object(dict)
|
|
671
|
+
ctx.obj['device_config'] = device_config
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
@click.command()
|
|
675
|
+
@click.option(
|
|
676
|
+
'--codec', type=click.Choice(['sbc', 'aac']), default='aac', show_default=True
|
|
677
|
+
)
|
|
678
|
+
@click.option(
|
|
679
|
+
'--discover', is_flag=True, help='Discover remote endpoints once connected'
|
|
680
|
+
)
|
|
681
|
+
@click.option(
|
|
682
|
+
'--output',
|
|
683
|
+
multiple=True,
|
|
684
|
+
metavar='NAME',
|
|
685
|
+
help=(
|
|
686
|
+
'Send audio to this named output '
|
|
687
|
+
'(may be used more than once for multiple outputs)'
|
|
688
|
+
),
|
|
689
|
+
)
|
|
690
|
+
@click.option(
|
|
691
|
+
'--ui-port',
|
|
692
|
+
'ui_port',
|
|
693
|
+
metavar='HTTP_PORT',
|
|
694
|
+
default=DEFAULT_UI_PORT,
|
|
695
|
+
show_default=True,
|
|
696
|
+
help='HTTP port for the UI server',
|
|
697
|
+
)
|
|
698
|
+
@click.option(
|
|
699
|
+
'--connect',
|
|
700
|
+
'connect_address',
|
|
701
|
+
metavar='ADDRESS_OR_NAME',
|
|
702
|
+
help='Address or name to connect to',
|
|
703
|
+
)
|
|
704
|
+
@click.option('--device-config', metavar='FILENAME', help='Device configuration file')
|
|
705
|
+
@click.argument('transport')
|
|
706
|
+
def speaker(
|
|
707
|
+
transport, codec, connect_address, discover, output, ui_port, device_config
|
|
708
|
+
):
|
|
709
|
+
"""Run the speaker."""
|
|
710
|
+
|
|
711
|
+
# ffplay only works with AAC for now
|
|
712
|
+
if codec != 'aac' and '@ffplay' in output:
|
|
713
|
+
print(
|
|
714
|
+
color(
|
|
715
|
+
f'{codec} not supported with @ffplay output, '
|
|
716
|
+
'@ffplay output will be skipped',
|
|
717
|
+
'yellow',
|
|
718
|
+
)
|
|
719
|
+
)
|
|
720
|
+
output = list(filter(lambda x: x != '@ffplay', output))
|
|
721
|
+
|
|
722
|
+
if '@ffplay' in output:
|
|
723
|
+
# Check if ffplay is installed
|
|
724
|
+
try:
|
|
725
|
+
subprocess.run(['ffplay', '-version'], capture_output=True, check=True)
|
|
726
|
+
except FileNotFoundError:
|
|
727
|
+
print(
|
|
728
|
+
color('ffplay not installed, @ffplay output will be disabled', 'yellow')
|
|
729
|
+
)
|
|
730
|
+
output = list(filter(lambda x: x != '@ffplay', output))
|
|
731
|
+
|
|
732
|
+
asyncio.run(
|
|
733
|
+
Speaker(device_config, transport, codec, discover, output, ui_port).run(
|
|
734
|
+
connect_address
|
|
735
|
+
)
|
|
736
|
+
)
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
# -----------------------------------------------------------------------------
|
|
740
|
+
def main():
|
|
741
|
+
logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'WARNING').upper())
|
|
742
|
+
speaker()
|
|
743
|
+
|
|
744
|
+
|
|
745
|
+
# -----------------------------------------------------------------------------
|
|
746
|
+
if __name__ == "__main__":
|
|
747
|
+
main() # pylint: disable=no-value-for-parameter
|