bumble 0.0.212__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.
- bumble/_version.py +2 -2
- bumble/a2dp.py +6 -0
- bumble/apps/README.md +0 -3
- bumble/apps/auracast.py +11 -9
- bumble/apps/bench.py +480 -31
- bumble/apps/console.py +3 -3
- bumble/apps/controller_info.py +47 -10
- bumble/apps/controller_loopback.py +7 -3
- bumble/apps/controllers.py +2 -2
- bumble/apps/device_info.py +2 -2
- bumble/apps/gatt_dump.py +2 -2
- bumble/apps/gg_bridge.py +2 -2
- bumble/apps/hci_bridge.py +2 -2
- bumble/apps/l2cap_bridge.py +2 -2
- bumble/apps/lea_unicast/app.py +6 -1
- bumble/apps/pair.py +19 -11
- bumble/apps/pandora_server.py +2 -2
- bumble/apps/rfcomm_bridge.py +1 -1
- bumble/apps/scan.py +2 -2
- bumble/apps/show.py +4 -2
- bumble/apps/speaker/speaker.html +1 -0
- bumble/apps/speaker/speaker.js +113 -62
- bumble/apps/speaker/speaker.py +126 -18
- bumble/at.py +4 -4
- bumble/att.py +2 -6
- bumble/avc.py +7 -7
- bumble/avctp.py +3 -3
- bumble/avdtp.py +16 -20
- bumble/avrcp.py +41 -53
- bumble/colors.py +2 -2
- bumble/controller.py +84 -23
- bumble/device.py +348 -182
- bumble/drivers/__init__.py +2 -2
- bumble/drivers/common.py +0 -2
- bumble/drivers/intel.py +37 -40
- bumble/drivers/rtk.py +28 -35
- bumble/gatt.py +4 -4
- bumble/gatt_adapters.py +4 -5
- bumble/gatt_client.py +26 -31
- bumble/gatt_server.py +7 -11
- bumble/hci.py +2601 -2909
- bumble/helpers.py +4 -5
- bumble/hfp.py +32 -37
- bumble/host.py +94 -35
- bumble/keys.py +5 -5
- bumble/l2cap.py +310 -394
- bumble/link.py +6 -270
- bumble/pairing.py +23 -20
- bumble/pandora/__init__.py +1 -1
- bumble/pandora/config.py +2 -2
- bumble/pandora/device.py +6 -6
- bumble/pandora/host.py +27 -28
- bumble/pandora/l2cap.py +2 -2
- bumble/pandora/security.py +6 -6
- bumble/pandora/utils.py +3 -3
- bumble/profiles/ascs.py +132 -131
- bumble/profiles/asha.py +2 -2
- bumble/profiles/bap.py +3 -4
- bumble/profiles/csip.py +2 -2
- bumble/profiles/device_information_service.py +2 -2
- bumble/profiles/gap.py +2 -2
- bumble/profiles/hap.py +34 -33
- bumble/profiles/le_audio.py +4 -4
- bumble/profiles/mcp.py +4 -4
- bumble/profiles/vcs.py +3 -5
- bumble/rfcomm.py +10 -10
- bumble/rtp.py +1 -2
- bumble/sdp.py +2 -2
- bumble/smp.py +57 -61
- bumble/tools/rtk_util.py +2 -2
- bumble/transport/__init__.py +2 -16
- bumble/transport/android_netsim.py +5 -5
- bumble/transport/common.py +4 -4
- bumble/transport/pyusb.py +2 -2
- bumble/utils.py +2 -5
- bumble/vendor/android/hci.py +118 -200
- bumble/vendor/zephyr/hci.py +32 -27
- {bumble-0.0.212.dist-info → bumble-0.0.213.dist-info}/METADATA +2 -2
- {bumble-0.0.212.dist-info → bumble-0.0.213.dist-info}/RECORD +83 -86
- {bumble-0.0.212.dist-info → bumble-0.0.213.dist-info}/WHEEL +1 -1
- {bumble-0.0.212.dist-info → bumble-0.0.213.dist-info}/entry_points.txt +0 -1
- bumble/apps/link_relay/__init__.py +0 -0
- bumble/apps/link_relay/link_relay.py +0 -289
- bumble/apps/link_relay/logging.yml +0 -21
- {bumble-0.0.212.dist-info → bumble-0.0.213.dist-info}/licenses/LICENSE +0 -0
- {bumble-0.0.212.dist-info → bumble-0.0.213.dist-info}/top_level.txt +0 -0
bumble/apps/speaker/speaker.js
CHANGED
|
@@ -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
|
|
15
|
-
let
|
|
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
|
-
|
|
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
|
|
71
|
-
|
|
72
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
}
|
bumble/apps/speaker/speaker.py
CHANGED
|
@@ -25,7 +25,7 @@ import os
|
|
|
25
25
|
import logging
|
|
26
26
|
import pathlib
|
|
27
27
|
import subprocess
|
|
28
|
-
from typing import
|
|
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__(
|
|
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) ->
|
|
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=
|
|
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=
|
|
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',
|
|
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,
|
|
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(
|
|
725
|
-
|
|
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(
|
|
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
|
|
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) ->
|
|
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) ->
|
|
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:
|
|
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:
|
|
250
|
+
pdu_classes: dict[int, type[ATT_PDU]] = {}
|
|
255
251
|
op_code = 0
|
|
256
252
|
name: str
|
|
257
253
|
|
|
@@ -818,7 +814,7 @@ class Attribute(utils.EventEmitter, Generic[_T]):
|
|
|
818
814
|
# The check for `p.name is not None` here is needed because for InFlag
|
|
819
815
|
# enums, the .name property can be None, when the enum value is 0,
|
|
820
816
|
# so the type hint for .name is Optional[str].
|
|
821
|
-
enum_list:
|
|
817
|
+
enum_list: list[str] = [p.name for p in cls if p.name is not None]
|
|
822
818
|
enum_list_str = ",".join(enum_list)
|
|
823
819
|
raise TypeError(
|
|
824
820
|
f"Attribute::permissions error:\nExpected a string containing any of the keys, separated by commas: {enum_list_str}\nGot: {permissions_str}"
|
bumble/avc.py
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
from __future__ import annotations
|
|
19
19
|
import enum
|
|
20
20
|
import struct
|
|
21
|
-
from typing import
|
|
21
|
+
from typing import Union
|
|
22
22
|
|
|
23
23
|
from bumble import core
|
|
24
24
|
from bumble import utils
|
|
@@ -213,11 +213,11 @@ class CommandFrame(Frame):
|
|
|
213
213
|
NOTIFY = 0x03
|
|
214
214
|
GENERAL_INQUIRY = 0x04
|
|
215
215
|
|
|
216
|
-
subclasses:
|
|
216
|
+
subclasses: dict[Frame.OperationCode, type[CommandFrame]] = {}
|
|
217
217
|
ctype: CommandType
|
|
218
218
|
|
|
219
219
|
@staticmethod
|
|
220
|
-
def parse_operands(operands: bytes) ->
|
|
220
|
+
def parse_operands(operands: bytes) -> tuple:
|
|
221
221
|
raise NotImplementedError
|
|
222
222
|
|
|
223
223
|
def __init__(
|
|
@@ -251,11 +251,11 @@ class ResponseFrame(Frame):
|
|
|
251
251
|
CHANGED = 0x0D
|
|
252
252
|
INTERIM = 0x0F
|
|
253
253
|
|
|
254
|
-
subclasses:
|
|
254
|
+
subclasses: dict[Frame.OperationCode, type[ResponseFrame]] = {}
|
|
255
255
|
response: ResponseCode
|
|
256
256
|
|
|
257
257
|
@staticmethod
|
|
258
|
-
def parse_operands(operands: bytes) ->
|
|
258
|
+
def parse_operands(operands: bytes) -> tuple:
|
|
259
259
|
raise NotImplementedError
|
|
260
260
|
|
|
261
261
|
def __init__(
|
|
@@ -282,7 +282,7 @@ class VendorDependentFrame:
|
|
|
282
282
|
vendor_dependent_data: bytes
|
|
283
283
|
|
|
284
284
|
@staticmethod
|
|
285
|
-
def parse_operands(operands: bytes) ->
|
|
285
|
+
def parse_operands(operands: bytes) -> tuple:
|
|
286
286
|
return (
|
|
287
287
|
struct.unpack(">I", b"\x00" + operands[:3])[0],
|
|
288
288
|
operands[3:],
|
|
@@ -432,7 +432,7 @@ class PassThroughFrame:
|
|
|
432
432
|
operation_data: bytes
|
|
433
433
|
|
|
434
434
|
@staticmethod
|
|
435
|
-
def parse_operands(operands: bytes) ->
|
|
435
|
+
def parse_operands(operands: bytes) -> tuple:
|
|
436
436
|
return (
|
|
437
437
|
PassThroughFrame.StateFlag(operands[0] >> 7),
|
|
438
438
|
PassThroughFrame.OperationId(operands[0] & 0x7F),
|
bumble/avctp.py
CHANGED
|
@@ -19,7 +19,7 @@ from __future__ import annotations
|
|
|
19
19
|
from enum import IntEnum
|
|
20
20
|
import logging
|
|
21
21
|
import struct
|
|
22
|
-
from typing import Callable, cast,
|
|
22
|
+
from typing import Callable, cast, Optional
|
|
23
23
|
|
|
24
24
|
from bumble.colors import color
|
|
25
25
|
from bumble import avc
|
|
@@ -146,9 +146,9 @@ class MessageAssembler:
|
|
|
146
146
|
# -----------------------------------------------------------------------------
|
|
147
147
|
class Protocol:
|
|
148
148
|
CommandHandler = Callable[[int, avc.CommandFrame], None]
|
|
149
|
-
command_handlers:
|
|
149
|
+
command_handlers: dict[int, CommandHandler] # Command handlers, by PID
|
|
150
150
|
ResponseHandler = Callable[[int, Optional[avc.ResponseFrame]], None]
|
|
151
|
-
response_handlers:
|
|
151
|
+
response_handlers: dict[int, ResponseHandler] # Response handlers, by PID
|
|
152
152
|
next_transaction_label: int
|
|
153
153
|
message_assembler: MessageAssembler
|
|
154
154
|
|