bumble 0.0.211__py3-none-any.whl → 0.0.213__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. bumble/_version.py +2 -2
  2. bumble/a2dp.py +6 -0
  3. bumble/apps/README.md +0 -3
  4. bumble/apps/auracast.py +11 -9
  5. bumble/apps/bench.py +482 -31
  6. bumble/apps/console.py +5 -5
  7. bumble/apps/controller_info.py +47 -10
  8. bumble/apps/controller_loopback.py +7 -3
  9. bumble/apps/controllers.py +2 -2
  10. bumble/apps/device_info.py +2 -2
  11. bumble/apps/gatt_dump.py +2 -2
  12. bumble/apps/gg_bridge.py +2 -2
  13. bumble/apps/hci_bridge.py +2 -2
  14. bumble/apps/l2cap_bridge.py +2 -2
  15. bumble/apps/lea_unicast/app.py +6 -1
  16. bumble/apps/pair.py +204 -43
  17. bumble/apps/pandora_server.py +2 -2
  18. bumble/apps/rfcomm_bridge.py +1 -1
  19. bumble/apps/scan.py +2 -2
  20. bumble/apps/show.py +4 -2
  21. bumble/apps/speaker/speaker.html +1 -0
  22. bumble/apps/speaker/speaker.js +113 -62
  23. bumble/apps/speaker/speaker.py +126 -18
  24. bumble/at.py +4 -4
  25. bumble/att.py +15 -18
  26. bumble/avc.py +7 -7
  27. bumble/avctp.py +5 -5
  28. bumble/avdtp.py +138 -88
  29. bumble/avrcp.py +52 -58
  30. bumble/colors.py +2 -2
  31. bumble/controller.py +84 -23
  32. bumble/core.py +13 -7
  33. bumble/{crypto.py → crypto/__init__.py} +11 -95
  34. bumble/crypto/builtin.py +652 -0
  35. bumble/crypto/cryptography.py +84 -0
  36. bumble/device.py +688 -345
  37. bumble/drivers/__init__.py +2 -2
  38. bumble/drivers/common.py +0 -2
  39. bumble/drivers/intel.py +40 -40
  40. bumble/drivers/rtk.py +28 -35
  41. bumble/gatt.py +7 -9
  42. bumble/gatt_adapters.py +4 -5
  43. bumble/gatt_client.py +31 -34
  44. bumble/gatt_server.py +15 -17
  45. bumble/hci.py +2635 -2878
  46. bumble/helpers.py +4 -5
  47. bumble/hfp.py +76 -57
  48. bumble/hid.py +24 -12
  49. bumble/host.py +117 -34
  50. bumble/keys.py +68 -52
  51. bumble/l2cap.py +329 -403
  52. bumble/link.py +6 -270
  53. bumble/pairing.py +23 -20
  54. bumble/pandora/__init__.py +1 -1
  55. bumble/pandora/config.py +2 -2
  56. bumble/pandora/device.py +6 -6
  57. bumble/pandora/host.py +38 -39
  58. bumble/pandora/l2cap.py +4 -4
  59. bumble/pandora/security.py +73 -57
  60. bumble/pandora/utils.py +3 -3
  61. bumble/profiles/aics.py +3 -5
  62. bumble/profiles/ancs.py +3 -1
  63. bumble/profiles/ascs.py +143 -136
  64. bumble/profiles/asha.py +13 -8
  65. bumble/profiles/bap.py +3 -4
  66. bumble/profiles/csip.py +3 -5
  67. bumble/profiles/device_information_service.py +2 -2
  68. bumble/profiles/gap.py +2 -2
  69. bumble/profiles/gatt_service.py +1 -3
  70. bumble/profiles/hap.py +42 -58
  71. bumble/profiles/le_audio.py +4 -4
  72. bumble/profiles/mcp.py +16 -13
  73. bumble/profiles/vcs.py +8 -10
  74. bumble/profiles/vocs.py +6 -9
  75. bumble/rfcomm.py +27 -18
  76. bumble/rtp.py +1 -2
  77. bumble/sdp.py +2 -2
  78. bumble/smp.py +71 -69
  79. bumble/tools/rtk_util.py +2 -2
  80. bumble/transport/__init__.py +2 -16
  81. bumble/transport/android_netsim.py +5 -5
  82. bumble/transport/common.py +4 -4
  83. bumble/transport/pyusb.py +2 -2
  84. bumble/utils.py +2 -5
  85. bumble/vendor/android/hci.py +118 -200
  86. bumble/vendor/zephyr/hci.py +32 -27
  87. {bumble-0.0.211.dist-info → bumble-0.0.213.dist-info}/METADATA +5 -5
  88. {bumble-0.0.211.dist-info → bumble-0.0.213.dist-info}/RECORD +92 -93
  89. {bumble-0.0.211.dist-info → bumble-0.0.213.dist-info}/WHEEL +1 -1
  90. {bumble-0.0.211.dist-info → bumble-0.0.213.dist-info}/entry_points.txt +0 -1
  91. bumble/apps/link_relay/__init__.py +0 -0
  92. bumble/apps/link_relay/link_relay.py +0 -289
  93. bumble/apps/link_relay/logging.yml +0 -21
  94. {bumble-0.0.211.dist-info → bumble-0.0.213.dist-info}/licenses/LICENSE +0 -0
  95. {bumble-0.0.211.dist-info → bumble-0.0.213.dist-info}/top_level.txt +0 -0
@@ -7,17 +7,19 @@ let connectionText;
7
7
  let codecText;
8
8
  let packetsReceivedText;
9
9
  let bytesReceivedText;
10
+ let bitrateText;
10
11
  let streamStateText;
11
12
  let connectionStateText;
12
13
  let controlsDiv;
13
14
  let audioOnButton;
14
- let mediaSource;
15
- let sourceBuffer;
16
- let audioElement;
15
+ let audioDecoder;
16
+ let audioCodec;
17
17
  let audioContext;
18
18
  let audioAnalyzer;
19
19
  let audioFrequencyBinCount;
20
20
  let audioFrequencyData;
21
+ let nextAudioStartPosition = 0;
22
+ let audioStartTime = 0;
21
23
  let packetsReceived = 0;
22
24
  let bytesReceived = 0;
23
25
  let audioState = "stopped";
@@ -29,20 +31,17 @@ let bandwidthCanvas;
29
31
  let bandwidthCanvasContext;
30
32
  let bandwidthBinCount;
31
33
  let bandwidthBins = [];
34
+ let bitrateSamples = [];
32
35
 
33
36
  const FFT_WIDTH = 800;
34
37
  const FFT_HEIGHT = 256;
35
38
  const BANDWIDTH_WIDTH = 500;
36
39
  const BANDWIDTH_HEIGHT = 100;
37
-
38
- function hexToBytes(hex) {
39
- return Uint8Array.from(hex.match(/.{1,2}/g).map((byte) => parseInt(byte, 16)));
40
- }
40
+ const BITRATE_WINDOW = 30;
41
41
 
42
42
  function init() {
43
43
  initUI();
44
- initMediaSource();
45
- initAudioElement();
44
+ initAudioContext();
46
45
  initAnalyzer();
47
46
 
48
47
  connect();
@@ -56,6 +55,7 @@ function initUI() {
56
55
  codecText = document.getElementById("codecText");
57
56
  packetsReceivedText = document.getElementById("packetsReceivedText");
58
57
  bytesReceivedText = document.getElementById("bytesReceivedText");
58
+ bitrateText = document.getElementById("bitrate");
59
59
  streamStateText = document.getElementById("streamStateText");
60
60
  connectionStateText = document.getElementById("connectionStateText");
61
61
  audioSupportMessageText = document.getElementById("audioSupportMessageText");
@@ -67,17 +67,9 @@ function initUI() {
67
67
  requestAnimationFrame(onAnimationFrame);
68
68
  }
69
69
 
70
- function initMediaSource() {
71
- mediaSource = new MediaSource();
72
- mediaSource.onsourceopen = onMediaSourceOpen;
73
- mediaSource.onsourceclose = onMediaSourceClose;
74
- mediaSource.onsourceended = onMediaSourceEnd;
75
- }
76
-
77
- function initAudioElement() {
78
- audioElement = document.getElementById("audio");
79
- audioElement.src = URL.createObjectURL(mediaSource);
80
- // audioElement.controls = true;
70
+ function initAudioContext() {
71
+ audioContext = new AudioContext();
72
+ audioContext.onstatechange = () => console.log("AudioContext state:", audioContext.state);
81
73
  }
82
74
 
83
75
  function initAnalyzer() {
@@ -94,24 +86,16 @@ function initAnalyzer() {
94
86
  bandwidthCanvasContext = bandwidthCanvas.getContext('2d');
95
87
  bandwidthCanvasContext.fillStyle = "rgb(255, 255, 255)";
96
88
  bandwidthCanvasContext.fillRect(0, 0, BANDWIDTH_WIDTH, BANDWIDTH_HEIGHT);
97
- }
98
-
99
- function startAnalyzer() {
100
- // FFT
101
- if (audioElement.captureStream !== undefined) {
102
- audioContext = new AudioContext();
103
- audioAnalyzer = audioContext.createAnalyser();
104
- audioAnalyzer.fftSize = 128;
105
- audioFrequencyBinCount = audioAnalyzer.frequencyBinCount;
106
- audioFrequencyData = new Uint8Array(audioFrequencyBinCount);
107
- const stream = audioElement.captureStream();
108
- const source = audioContext.createMediaStreamSource(stream);
109
- source.connect(audioAnalyzer);
110
- }
111
-
112
- // Bandwidth
113
89
  bandwidthBinCount = BANDWIDTH_WIDTH / 2;
114
90
  bandwidthBins = [];
91
+ bitrateSamples = [];
92
+
93
+ audioAnalyzer = audioContext.createAnalyser();
94
+ audioAnalyzer.fftSize = 128;
95
+ audioFrequencyBinCount = audioAnalyzer.frequencyBinCount;
96
+ audioFrequencyData = new Uint8Array(audioFrequencyBinCount);
97
+
98
+ audioAnalyzer.connect(audioContext.destination)
115
99
  }
116
100
 
117
101
  function setConnectionText(message) {
@@ -148,7 +132,8 @@ function onAnimationFrame() {
148
132
  bandwidthCanvasContext.fillRect(0, 0, BANDWIDTH_WIDTH, BANDWIDTH_HEIGHT);
149
133
  bandwidthCanvasContext.fillStyle = `rgb(100, 100, 100)`;
150
134
  for (let t = 0; t < bandwidthBins.length; t++) {
151
- const lineHeight = (bandwidthBins[t] / 1000) * BANDWIDTH_HEIGHT;
135
+ const bytesReceived = bandwidthBins[t]
136
+ const lineHeight = (bytesReceived / 1000) * BANDWIDTH_HEIGHT;
152
137
  bandwidthCanvasContext.fillRect(t * 2, BANDWIDTH_HEIGHT - lineHeight, 2, lineHeight);
153
138
  }
154
139
 
@@ -156,28 +141,14 @@ function onAnimationFrame() {
156
141
  requestAnimationFrame(onAnimationFrame);
157
142
  }
158
143
 
159
- function onMediaSourceOpen() {
160
- console.log(this.readyState);
161
- sourceBuffer = mediaSource.addSourceBuffer("audio/aac");
162
- }
163
-
164
- function onMediaSourceClose() {
165
- console.log(this.readyState);
166
- }
167
-
168
- function onMediaSourceEnd() {
169
- console.log(this.readyState);
170
- }
171
-
172
144
  async function startAudio() {
173
145
  try {
174
146
  console.log("starting audio...");
175
147
  audioOnButton.disabled = true;
176
148
  audioState = "starting";
177
- await audioElement.play();
149
+ audioContext.resume();
178
150
  console.log("audio started");
179
151
  audioState = "playing";
180
- startAnalyzer();
181
152
  } catch(error) {
182
153
  console.error(`play failed: ${error}`);
183
154
  audioState = "stopped";
@@ -185,12 +156,47 @@ async function startAudio() {
185
156
  }
186
157
  }
187
158
 
188
- function onAudioPacket(packet) {
189
- if (audioState != "stopped") {
190
- // Queue the audio packet.
191
- sourceBuffer.appendBuffer(packet);
159
+ function onDecodedAudio(audioData) {
160
+ const bufferSource = audioContext.createBufferSource()
161
+
162
+ const now = audioContext.currentTime;
163
+ let nextAudioStartTime = audioStartTime + (nextAudioStartPosition / audioData.sampleRate);
164
+ if (nextAudioStartTime < now) {
165
+ console.log("starting new audio time base")
166
+ audioStartTime = now;
167
+ nextAudioStartTime = now;
168
+ nextAudioStartPosition = 0;
169
+ } else {
170
+ console.log(`audio buffer scheduled in ${nextAudioStartTime - now}`)
171
+ }
172
+
173
+ const audioBuffer = audioContext.createBuffer(
174
+ audioData.numberOfChannels,
175
+ audioData.numberOfFrames,
176
+ audioData.sampleRate
177
+ );
178
+
179
+ for (let channel = 0; channel < audioData.numberOfChannels; channel++) {
180
+ audioData.copyTo(
181
+ audioBuffer.getChannelData(channel),
182
+ {
183
+ planeIndex: channel,
184
+ format: "f32-planar"
185
+ }
186
+ )
192
187
  }
193
188
 
189
+ bufferSource.buffer = audioBuffer;
190
+ bufferSource.connect(audioAnalyzer)
191
+ bufferSource.start(nextAudioStartTime);
192
+ nextAudioStartPosition += audioData.numberOfFrames;
193
+ }
194
+
195
+ function onCodecError(error) {
196
+ console.log("Codec error:", error)
197
+ }
198
+
199
+ async function onAudioPacket(packet) {
194
200
  packetsReceived += 1;
195
201
  packetsReceivedText.innerText = packetsReceived;
196
202
  bytesReceived += packet.byteLength;
@@ -200,6 +206,48 @@ function onAudioPacket(packet) {
200
206
  if (bandwidthBins.length > bandwidthBinCount) {
201
207
  bandwidthBins.shift();
202
208
  }
209
+ bitrateSamples[bitrateSamples.length] = {ts: Date.now(), bytes: packet.byteLength}
210
+ if (bitrateSamples.length > BITRATE_WINDOW) {
211
+ bitrateSamples.shift();
212
+ }
213
+ if (bitrateSamples.length >= 2) {
214
+ const windowBytes = bitrateSamples.reduce((accumulator, x) => accumulator + x.bytes, 0) - bitrateSamples[0].bytes;
215
+ const elapsed = bitrateSamples[bitrateSamples.length-1].ts - bitrateSamples[0].ts;
216
+ const bitrate = Math.floor(8 * windowBytes / elapsed)
217
+ bitrateText.innerText = `${bitrate} kb/s`
218
+ }
219
+
220
+ if (audioState == "stopped") {
221
+ return;
222
+ }
223
+
224
+ if (audioDecoder === undefined) {
225
+ let audioConfig;
226
+ if (audioCodec == 'aac') {
227
+ audioConfig = {
228
+ codec: 'mp4a.40.2',
229
+ sampleRate: 44100, // ignored
230
+ numberOfChannels: 2, // ignored
231
+ }
232
+ } else if (audioCodec == 'opus') {
233
+ audioConfig = {
234
+ codec: 'opus',
235
+ sampleRate: 48000, // ignored
236
+ numberOfChannels: 2, // ignored
237
+ }
238
+ }
239
+ audioDecoder = new AudioDecoder({ output: onDecodedAudio, error: onCodecError });
240
+ audioDecoder.configure(audioConfig)
241
+ }
242
+
243
+ const encodedAudio = new EncodedAudioChunk({
244
+ type: "key",
245
+ data: packet,
246
+ timestamp: 0,
247
+ transfer: [packet],
248
+ });
249
+
250
+ audioDecoder.decode(encodedAudio);
203
251
  }
204
252
 
205
253
  function onChannelOpen() {
@@ -249,16 +297,19 @@ function onChannelMessage(message) {
249
297
  }
250
298
  }
251
299
 
252
- function onHelloMessage(params) {
300
+ async function onHelloMessage(params) {
253
301
  codecText.innerText = params.codec;
254
- if (params.codec != "aac") {
255
- audioOnButton.disabled = true;
256
- audioSupportMessageText.innerText = "Only AAC can be played, audio will be disabled";
257
- audioSupportMessageText.style.display = "inline-block";
258
- } else {
302
+
303
+ if (params.codec == "aac" || params.codec == "opus") {
304
+ audioCodec = params.codec
259
305
  audioSupportMessageText.innerText = "";
260
306
  audioSupportMessageText.style.display = "none";
307
+ } else {
308
+ audioOnButton.disabled = true;
309
+ audioSupportMessageText.innerText = "Only AAC and Opus can be played, audio will be disabled";
310
+ audioSupportMessageText.style.display = "inline-block";
261
311
  }
312
+
262
313
  if (params.streamState) {
263
314
  setStreamState(params.streamState);
264
315
  }
@@ -25,7 +25,7 @@ import os
25
25
  import logging
26
26
  import pathlib
27
27
  import subprocess
28
- from typing import Dict, List, Optional
28
+ from typing import Optional
29
29
  import weakref
30
30
 
31
31
  import click
@@ -50,8 +50,10 @@ from bumble.a2dp import (
50
50
  make_audio_sink_service_sdp_records,
51
51
  A2DP_SBC_CODEC_TYPE,
52
52
  A2DP_MPEG_2_4_AAC_CODEC_TYPE,
53
+ A2DP_NON_A2DP_CODEC_TYPE,
53
54
  SbcMediaCodecInformation,
54
55
  AacMediaCodecInformation,
56
+ OpusMediaCodecInformation,
55
57
  )
56
58
  from bumble.utils import AsyncRunner
57
59
  from bumble.codecs import AacAudioRtpPacket
@@ -78,6 +80,8 @@ class AudioExtractor:
78
80
  return AacAudioExtractor()
79
81
  if codec == 'sbc':
80
82
  return SbcAudioExtractor()
83
+ if codec == 'opus':
84
+ return OpusAudioExtractor()
81
85
 
82
86
  def extract_audio(self, packet: MediaPacket) -> bytes:
83
87
  raise NotImplementedError()
@@ -102,6 +106,13 @@ class SbcAudioExtractor:
102
106
  return packet.payload[1:]
103
107
 
104
108
 
109
+ # -----------------------------------------------------------------------------
110
+ class OpusAudioExtractor:
111
+ def extract_audio(self, packet: MediaPacket) -> bytes:
112
+ # TODO: parse fields
113
+ return packet.payload[1:]
114
+
115
+
105
116
  # -----------------------------------------------------------------------------
106
117
  class Output:
107
118
  async def start(self) -> None:
@@ -235,7 +246,7 @@ class FfplayOutput(QueuedOutput):
235
246
  await super().start()
236
247
 
237
248
  self.subprocess = await asyncio.create_subprocess_shell(
238
- f'ffplay -f {self.codec} pipe:0',
249
+ f'ffplay -probesize 32 -f {self.codec} pipe:0',
239
250
  stdin=asyncio.subprocess.PIPE,
240
251
  stdout=asyncio.subprocess.PIPE,
241
252
  stderr=asyncio.subprocess.PIPE,
@@ -399,10 +410,24 @@ class Speaker:
399
410
  STARTED = 2
400
411
  SUSPENDED = 3
401
412
 
402
- def __init__(self, device_config, transport, codec, discover, outputs, ui_port):
413
+ def __init__(
414
+ self,
415
+ device_config,
416
+ transport,
417
+ codec,
418
+ sampling_frequencies,
419
+ bitrate,
420
+ vbr,
421
+ discover,
422
+ outputs,
423
+ ui_port,
424
+ ):
403
425
  self.device_config = device_config
404
426
  self.transport = transport
405
427
  self.codec = codec
428
+ self.sampling_frequencies = sampling_frequencies
429
+ self.bitrate = bitrate
430
+ self.vbr = vbr
406
431
  self.discover = discover
407
432
  self.ui_port = ui_port
408
433
  self.device = None
@@ -423,7 +448,7 @@ class Speaker:
423
448
  # Create an HTTP server for the UI
424
449
  self.ui_server = UiServer(speaker=self, port=ui_port)
425
450
 
426
- def sdp_records(self) -> Dict[int, List[ServiceAttribute]]:
451
+ def sdp_records(self) -> dict[int, list[ServiceAttribute]]:
427
452
  service_record_handle = 0x00010001
428
453
  return {
429
454
  service_record_handle: make_audio_sink_service_sdp_records(
@@ -438,32 +463,56 @@ class Speaker:
438
463
  if self.codec == 'sbc':
439
464
  return self.sbc_codec_capabilities()
440
465
 
466
+ if self.codec == 'opus':
467
+ return self.opus_codec_capabilities()
468
+
441
469
  raise RuntimeError('unsupported codec')
442
470
 
443
471
  def aac_codec_capabilities(self) -> MediaCodecCapabilities:
472
+ supported_sampling_frequencies = AacMediaCodecInformation.SamplingFrequency(0)
473
+ for sampling_frequency in self.sampling_frequencies or [
474
+ 8000,
475
+ 11025,
476
+ 12000,
477
+ 16000,
478
+ 22050,
479
+ 24000,
480
+ 32000,
481
+ 44100,
482
+ 48000,
483
+ ]:
484
+ supported_sampling_frequencies |= (
485
+ AacMediaCodecInformation.SamplingFrequency.from_int(sampling_frequency)
486
+ )
444
487
  return MediaCodecCapabilities(
445
488
  media_type=AVDTP_AUDIO_MEDIA_TYPE,
446
489
  media_codec_type=A2DP_MPEG_2_4_AAC_CODEC_TYPE,
447
490
  media_codec_information=AacMediaCodecInformation(
448
491
  object_type=AacMediaCodecInformation.ObjectType.MPEG_2_AAC_LC,
449
- sampling_frequency=AacMediaCodecInformation.SamplingFrequency.SF_48000
450
- | AacMediaCodecInformation.SamplingFrequency.SF_44100,
492
+ sampling_frequency=supported_sampling_frequencies,
451
493
  channels=AacMediaCodecInformation.Channels.MONO
452
494
  | AacMediaCodecInformation.Channels.STEREO,
453
- vbr=1,
454
- bitrate=256000,
495
+ vbr=1 if self.vbr else 0,
496
+ bitrate=self.bitrate or 256000,
455
497
  ),
456
498
  )
457
499
 
458
500
  def sbc_codec_capabilities(self) -> MediaCodecCapabilities:
501
+ supported_sampling_frequencies = SbcMediaCodecInformation.SamplingFrequency(0)
502
+ for sampling_frequency in self.sampling_frequencies or [
503
+ 16000,
504
+ 32000,
505
+ 44100,
506
+ 48000,
507
+ ]:
508
+ supported_sampling_frequencies |= (
509
+ SbcMediaCodecInformation.SamplingFrequency.from_int(sampling_frequency)
510
+ )
459
511
  return MediaCodecCapabilities(
460
512
  media_type=AVDTP_AUDIO_MEDIA_TYPE,
461
513
  media_codec_type=A2DP_SBC_CODEC_TYPE,
462
514
  media_codec_information=SbcMediaCodecInformation(
463
- sampling_frequency=SbcMediaCodecInformation.SamplingFrequency.SF_48000
464
- | SbcMediaCodecInformation.SamplingFrequency.SF_44100
465
- | SbcMediaCodecInformation.SamplingFrequency.SF_32000
466
- | SbcMediaCodecInformation.SamplingFrequency.SF_16000,
515
+ sampling_frequency=supported_sampling_frequencies,
467
516
  channel_mode=SbcMediaCodecInformation.ChannelMode.MONO
468
517
  | SbcMediaCodecInformation.ChannelMode.DUAL_CHANNEL
469
518
  | SbcMediaCodecInformation.ChannelMode.STEREO
@@ -481,6 +530,25 @@ class Speaker:
481
530
  ),
482
531
  )
483
532
 
533
+ def opus_codec_capabilities(self) -> MediaCodecCapabilities:
534
+ supported_sampling_frequencies = OpusMediaCodecInformation.SamplingFrequency(0)
535
+ for sampling_frequency in self.sampling_frequencies or [48000]:
536
+ supported_sampling_frequencies |= (
537
+ OpusMediaCodecInformation.SamplingFrequency.from_int(sampling_frequency)
538
+ )
539
+ return MediaCodecCapabilities(
540
+ media_type=AVDTP_AUDIO_MEDIA_TYPE,
541
+ media_codec_type=A2DP_NON_A2DP_CODEC_TYPE,
542
+ media_codec_information=OpusMediaCodecInformation(
543
+ frame_size=OpusMediaCodecInformation.FrameSize.FS_10MS
544
+ | OpusMediaCodecInformation.FrameSize.FS_20MS,
545
+ channel_mode=OpusMediaCodecInformation.ChannelMode.MONO
546
+ | OpusMediaCodecInformation.ChannelMode.STEREO
547
+ | OpusMediaCodecInformation.ChannelMode.DUAL_MONO,
548
+ sampling_frequency=supported_sampling_frequencies,
549
+ ),
550
+ )
551
+
484
552
  async def dispatch_to_outputs(self, function):
485
553
  for output in self.outputs:
486
554
  await function(output)
@@ -675,7 +743,26 @@ def speaker_cli(ctx, device_config):
675
743
 
676
744
  @click.command()
677
745
  @click.option(
678
- '--codec', type=click.Choice(['sbc', 'aac']), default='aac', show_default=True
746
+ '--codec',
747
+ type=click.Choice(['sbc', 'aac', 'opus']),
748
+ default='aac',
749
+ show_default=True,
750
+ )
751
+ @click.option(
752
+ '--sampling-frequency',
753
+ metavar='SAMPLING-FREQUENCY',
754
+ type=int,
755
+ multiple=True,
756
+ help='Enable a sampling frequency (may be specified more than once)',
757
+ )
758
+ @click.option(
759
+ '--bitrate',
760
+ metavar='BITRATE',
761
+ type=int,
762
+ help='Supported bitrate (AAC only)',
763
+ )
764
+ @click.option(
765
+ '--vbr/--no-vbr', is_flag=True, default=True, help='Enable VBR (AAC only)'
679
766
  )
680
767
  @click.option(
681
768
  '--discover', is_flag=True, help='Discover remote endpoints once connected'
@@ -706,7 +793,16 @@ def speaker_cli(ctx, device_config):
706
793
  @click.option('--device-config', metavar='FILENAME', help='Device configuration file')
707
794
  @click.argument('transport')
708
795
  def speaker(
709
- transport, codec, connect_address, discover, output, ui_port, device_config
796
+ transport,
797
+ codec,
798
+ sampling_frequency,
799
+ bitrate,
800
+ vbr,
801
+ connect_address,
802
+ discover,
803
+ output,
804
+ ui_port,
805
+ device_config,
710
806
  ):
711
807
  """Run the speaker."""
712
808
 
@@ -721,15 +817,27 @@ def speaker(
721
817
  output = list(filter(lambda x: x != '@ffplay', output))
722
818
 
723
819
  asyncio.run(
724
- Speaker(device_config, transport, codec, discover, output, ui_port).run(
725
- connect_address
726
- )
820
+ Speaker(
821
+ device_config,
822
+ transport,
823
+ codec,
824
+ sampling_frequency,
825
+ bitrate,
826
+ vbr,
827
+ discover,
828
+ output,
829
+ ui_port,
830
+ ).run(connect_address)
727
831
  )
728
832
 
729
833
 
730
834
  # -----------------------------------------------------------------------------
731
835
  def main():
732
- logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'WARNING').upper())
836
+ logging.basicConfig(
837
+ level=os.environ.get('BUMBLE_LOGLEVEL', 'WARNING').upper(),
838
+ format="[%(asctime)s.%(msecs)03d] %(levelname)s:%(name)s:%(message)s",
839
+ datefmt="%H:%M:%S",
840
+ )
733
841
  speaker()
734
842
 
735
843
 
bumble/at.py CHANGED
@@ -12,7 +12,7 @@
12
12
  # See the License for the specific language governing permissions and
13
13
  # limitations under the License.
14
14
 
15
- from typing import List, Union
15
+ from typing import Union
16
16
 
17
17
  from bumble import core
18
18
 
@@ -21,7 +21,7 @@ class AtParsingError(core.InvalidPacketError):
21
21
  """Error raised when parsing AT commands fails."""
22
22
 
23
23
 
24
- def tokenize_parameters(buffer: bytes) -> List[bytes]:
24
+ def tokenize_parameters(buffer: bytes) -> list[bytes]:
25
25
  """Split input parameters into tokens.
26
26
  Removes space characters outside of double quote blocks:
27
27
  T-rec-V-25 - 5.2.1 Command line general format: "Space characters (IA5 2/0)
@@ -63,12 +63,12 @@ def tokenize_parameters(buffer: bytes) -> List[bytes]:
63
63
  return [bytes(token) for token in tokens if len(token) > 0]
64
64
 
65
65
 
66
- def parse_parameters(buffer: bytes) -> List[Union[bytes, list]]:
66
+ def parse_parameters(buffer: bytes) -> list[Union[bytes, list]]:
67
67
  """Parse the parameters using the comma and parenthesis separators.
68
68
  Raises AtParsingError in case of invalid input string."""
69
69
 
70
70
  tokens = tokenize_parameters(buffer)
71
- accumulator: List[list] = [[]]
71
+ accumulator: list[list] = [[]]
72
72
  current: Union[bytes, list] = bytes()
73
73
 
74
74
  for token in tokens:
bumble/att.py CHANGED
@@ -32,10 +32,6 @@ from typing import (
32
32
  Awaitable,
33
33
  Callable,
34
34
  Generic,
35
- Dict,
36
- List,
37
- Optional,
38
- Type,
39
35
  TypeVar,
40
36
  Union,
41
37
  TYPE_CHECKING,
@@ -251,7 +247,7 @@ class ATT_PDU:
251
247
  See Bluetooth spec @ Vol 3, Part F - 3.3 ATTRIBUTE PDU
252
248
  '''
253
249
 
254
- pdu_classes: Dict[int, Type[ATT_PDU]] = {}
250
+ pdu_classes: dict[int, type[ATT_PDU]] = {}
255
251
  op_code = 0
256
252
  name: str
257
253
 
@@ -770,27 +766,25 @@ class AttributeValue(Generic[_T]):
770
766
  def __init__(
771
767
  self,
772
768
  read: Union[
773
- Callable[[Optional[Connection]], _T],
774
- Callable[[Optional[Connection]], Awaitable[_T]],
769
+ Callable[[Connection], _T],
770
+ Callable[[Connection], Awaitable[_T]],
775
771
  None,
776
772
  ] = None,
777
773
  write: Union[
778
- Callable[[Optional[Connection], _T], None],
779
- Callable[[Optional[Connection], _T], Awaitable[None]],
774
+ Callable[[Connection, _T], None],
775
+ Callable[[Connection, _T], Awaitable[None]],
780
776
  None,
781
777
  ] = None,
782
778
  ):
783
779
  self._read = read
784
780
  self._write = write
785
781
 
786
- def read(self, connection: Optional[Connection]) -> Union[_T, Awaitable[_T]]:
782
+ def read(self, connection: Connection) -> Union[_T, Awaitable[_T]]:
787
783
  if self._read is None:
788
784
  raise InvalidOperationError('AttributeValue has no read function')
789
785
  return self._read(connection)
790
786
 
791
- def write(
792
- self, connection: Optional[Connection], value: _T
793
- ) -> Union[Awaitable[None], None]:
787
+ def write(self, connection: Connection, value: _T) -> Union[Awaitable[None], None]:
794
788
  if self._write is None:
795
789
  raise InvalidOperationError('AttributeValue has no write function')
796
790
  return self._write(connection, value)
@@ -820,7 +814,7 @@ class Attribute(utils.EventEmitter, Generic[_T]):
820
814
  # The check for `p.name is not None` here is needed because for InFlag
821
815
  # enums, the .name property can be None, when the enum value is 0,
822
816
  # so the type hint for .name is Optional[str].
823
- enum_list: List[str] = [p.name for p in cls if p.name is not None]
817
+ enum_list: list[str] = [p.name for p in cls if p.name is not None]
824
818
  enum_list_str = ",".join(enum_list)
825
819
  raise TypeError(
826
820
  f"Attribute::permissions error:\nExpected a string containing any of the keys, separated by commas: {enum_list_str}\nGot: {permissions_str}"
@@ -836,6 +830,9 @@ class Attribute(utils.EventEmitter, Generic[_T]):
836
830
  READ_REQUIRES_AUTHORIZATION = Permissions.READ_REQUIRES_AUTHORIZATION
837
831
  WRITE_REQUIRES_AUTHORIZATION = Permissions.WRITE_REQUIRES_AUTHORIZATION
838
832
 
833
+ EVENT_READ = "read"
834
+ EVENT_WRITE = "write"
835
+
839
836
  value: Union[AttributeValue[_T], _T, None]
840
837
 
841
838
  def __init__(
@@ -868,7 +865,7 @@ class Attribute(utils.EventEmitter, Generic[_T]):
868
865
  def decode_value(self, value: bytes) -> _T:
869
866
  return value # type: ignore
870
867
 
871
- async def read_value(self, connection: Optional[Connection]) -> bytes:
868
+ async def read_value(self, connection: Connection) -> bytes:
872
869
  if (
873
870
  (self.permissions & self.READ_REQUIRES_ENCRYPTION)
874
871
  and connection is not None
@@ -906,11 +903,11 @@ class Attribute(utils.EventEmitter, Generic[_T]):
906
903
  else:
907
904
  value = self.value
908
905
 
909
- self.emit('read', connection, b'' if value is None else value)
906
+ self.emit(self.EVENT_READ, connection, b'' if value is None else value)
910
907
 
911
908
  return b'' if value is None else self.encode_value(value)
912
909
 
913
- async def write_value(self, connection: Optional[Connection], value: bytes) -> None:
910
+ async def write_value(self, connection: Connection, value: bytes) -> None:
914
911
  if (
915
912
  (self.permissions & self.WRITE_REQUIRES_ENCRYPTION)
916
913
  and connection is not None
@@ -947,7 +944,7 @@ class Attribute(utils.EventEmitter, Generic[_T]):
947
944
  else:
948
945
  self.value = decoded_value
949
946
 
950
- self.emit('write', connection, decoded_value)
947
+ self.emit(self.EVENT_WRITE, connection, decoded_value)
951
948
 
952
949
  def __repr__(self):
953
950
  if isinstance(self.value, bytes):