From b6e1d569d36a08f46bffa16e24c30c67b21066e5 Mon Sep 17 00:00:00 2001 From: Gilles Boccon-Gibod Date: Tue, 25 Apr 2023 14:15:41 -0700 Subject: [PATCH 01/15] a2dp and avdtp improvements --- bumble/a2dp.py | 1 + bumble/avdtp.py | 81 ++++++++++++++++++++++++++++++------------------- bumble/hci.py | 2 +- 3 files changed, 52 insertions(+), 32 deletions(-) diff --git a/bumble/a2dp.py b/bumble/a2dp.py index 772846a3..eeecb1ee 100644 --- a/bumble/a2dp.py +++ b/bumble/a2dp.py @@ -432,6 +432,7 @@ class AacMediaCodecInformation( cls.SAMPLING_FREQUENCY_BITS[x] for x in sampling_frequencies ), channels=sum(cls.CHANNELS_BITS[x] for x in channels), + rfa=0, vbr=vbr, bitrate=bitrate, ) diff --git a/bumble/avdtp.py b/bumble/avdtp.py index 238036dc..3988f309 100644 --- a/bumble/avdtp.py +++ b/bumble/avdtp.py @@ -1207,7 +1207,7 @@ class DelayReport_Reject(Simple_Reject): # ----------------------------------------------------------------------------- -class Protocol: +class Protocol(EventEmitter): SINGLE_PACKET = 0 START_PACKET = 1 CONTINUE_PACKET = 2 @@ -1234,6 +1234,7 @@ class Protocol: return protocol def __init__(self, l2cap_channel, version=(1, 3)): + super().__init__() self.l2cap_channel = l2cap_channel self.version = version self.rtx_sig_timer = AVDTP_DEFAULT_RTX_SIG_TIMER @@ -1250,6 +1251,7 @@ class Protocol: # Register to receive PDUs from the channel l2cap_channel.sink = self.on_pdu l2cap_channel.on('open', self.on_l2cap_channel_open) + l2cap_channel.on('close', self.on_l2cap_channel_close) def get_local_endpoint_by_seid(self, seid): if 0 < seid <= len(self.local_endpoints): @@ -1392,11 +1394,18 @@ class Protocol: def on_l2cap_connection(self, channel): # Forward the channel to the endpoint that's expecting it - if self.channel_acceptor: - self.channel_acceptor.on_l2cap_connection(channel) + if self.channel_acceptor is None: + logger.warning(color('!!! l2cap connection with no acceptor', 'red')) + return + self.channel_acceptor.on_l2cap_connection(channel) def on_l2cap_channel_open(self): logger.debug(color('<<< L2CAP channel open', 'magenta')) + self.emit('open') + + def on_l2cap_channel_close(self): + logger.debug(color('<<< L2CAP channel close', 'magenta')) + self.emit('close') def send_message(self, transaction_label, message): logger.debug( @@ -1651,6 +1660,10 @@ class Listener(EventEmitter): def set_server(self, connection, server): self.servers[connection.handle] = server + def remove_server(self, connection): + if connection.handle in self.servers: + del self.servers[connection.handle] + def __init__(self, registrar, version=(1, 3)): super().__init__() self.version = version @@ -1669,11 +1682,17 @@ class Listener(EventEmitter): else: # This is a new command/response channel def on_channel_open(): + logger.debug('setting up new Protocol for the connection') server = Protocol(channel, self.version) self.set_server(channel.connection, server) self.emit('connection', server) + def on_channel_close(): + logger.debug('removing Protocol for the connection') + self.remove_server(channel.connection) + channel.on('open', on_channel_open) + channel.on('close', on_channel_close) # ----------------------------------------------------------------------------- @@ -1967,11 +1986,12 @@ class DiscoveredStreamEndPoint(StreamEndPoint, StreamEndPointProxy): # ----------------------------------------------------------------------------- -class LocalStreamEndPoint(StreamEndPoint): +class LocalStreamEndPoint(StreamEndPoint, EventEmitter): def __init__( self, protocol, seid, media_type, tsep, capabilities, configuration=None ): - super().__init__(seid, media_type, tsep, 0, capabilities) + StreamEndPoint.__init__(self, seid, media_type, tsep, 0, capabilities) + EventEmitter.__init__(self) self.protocol = protocol self.configuration = configuration if configuration is not None else [] self.stream = None @@ -1988,40 +2008,47 @@ class LocalStreamEndPoint(StreamEndPoint): def on_reconfigure_command(self, command): pass + def on_set_configuration_command(self, configuration): + logger.debug( + '<<< received configuration: ' + f'{",".join([str(capability) for capability in configuration])}' + ) + self.configuration = configuration + self.emit('configuration') + def on_get_configuration_command(self): return Get_Configuration_Response(self.configuration) def on_open_command(self): - pass + self.emit('open') def on_start_command(self): - pass + self.emit('start') def on_suspend_command(self): - pass + self.emit('suspend') def on_close_command(self): - pass + self.emit('close') def on_abort_command(self): - pass + self.emit('abort') def on_rtp_channel_open(self): - pass + self.emit('rtp_channel_open') def on_rtp_channel_close(self): - pass + self.emit('rtp_channel_close') # ----------------------------------------------------------------------------- -class LocalSource(LocalStreamEndPoint, EventEmitter): +class LocalSource(LocalStreamEndPoint): def __init__(self, protocol, seid, codec_capabilities, packet_pump): capabilities = [ ServiceCapabilities(AVDTP_MEDIA_TRANSPORT_SERVICE_CATEGORY), codec_capabilities, ] - LocalStreamEndPoint.__init__( - self, + super().__init__( protocol, seid, codec_capabilities.media_type, @@ -2029,14 +2056,13 @@ class LocalSource(LocalStreamEndPoint, EventEmitter): capabilities, capabilities, ) - EventEmitter.__init__(self) self.packet_pump = packet_pump async def start(self): if self.packet_pump: return await self.packet_pump.start(self.stream.rtp_channel) - self.emit('start', self.stream.rtp_channel) + self.emit('start') async def stop(self): if self.packet_pump: @@ -2044,11 +2070,6 @@ class LocalSource(LocalStreamEndPoint, EventEmitter): self.emit('stop') - def on_set_configuration_command(self, configuration): - # For now, blindly accept the configuration - logger.debug(f'<<< received source configuration: {configuration}') - self.configuration = configuration - def on_start_command(self): asyncio.create_task(self.start()) @@ -2057,30 +2078,28 @@ class LocalSource(LocalStreamEndPoint, EventEmitter): # ----------------------------------------------------------------------------- -class LocalSink(LocalStreamEndPoint, EventEmitter): +class LocalSink(LocalStreamEndPoint): def __init__(self, protocol, seid, codec_capabilities): capabilities = [ ServiceCapabilities(AVDTP_MEDIA_TRANSPORT_SERVICE_CATEGORY), codec_capabilities, ] - LocalStreamEndPoint.__init__( - self, + super().__init__( protocol, seid, codec_capabilities.media_type, AVDTP_TSEP_SNK, capabilities, ) - EventEmitter.__init__(self) - - def on_set_configuration_command(self, configuration): - # For now, blindly accept the configuration - logger.debug(f'<<< received sink configuration: {configuration}') - self.configuration = configuration def on_rtp_channel_open(self): logger.debug(color('<<< RTP channel open', 'magenta')) self.stream.rtp_channel.sink = self.on_avdtp_packet + super().on_rtp_channel_open() + + def on_rtp_channel_close(self): + logger.debug(color('<<< RTP channel close', 'magenta')) + super().on_rtp_channel_close() def on_avdtp_packet(self, packet): rtp_packet = MediaPacket.from_bytes(packet) diff --git a/bumble/hci.py b/bumble/hci.py index 9b5793d4..43494269 100644 --- a/bumble/hci.py +++ b/bumble/hci.py @@ -5373,7 +5373,7 @@ class HCI_AclDataPacket: def __str__(self): return ( f'{color("ACL", "blue")}: ' - f'handle=0x{self.connection_handle:04x}' + f'handle=0x{self.connection_handle:04x}, ' f'pb={self.pb_flag}, bc={self.bc_flag}, ' f'data_total_length={self.data_total_length}, ' f'data={self.data.hex()}' From e6a623db93356a51e1cc02e28f06679a667c34ae Mon Sep 17 00:00:00 2001 From: Gilles Boccon-Gibod Date: Tue, 25 Apr 2023 14:16:28 -0700 Subject: [PATCH 02/15] initial speaker app skeleton --- apps/speaker/__init__.py | 0 apps/speaker/speaker.py | 277 +++++++++++++++++++++++++++++++++++++++ bumble/device.py | 8 +- setup.cfg | 1 + 4 files changed, 284 insertions(+), 2 deletions(-) create mode 100644 apps/speaker/__init__.py create mode 100644 apps/speaker/speaker.py diff --git a/apps/speaker/__init__.py b/apps/speaker/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/speaker/speaker.py b/apps/speaker/speaker.py new file mode 100644 index 00000000..67afc3d0 --- /dev/null +++ b/apps/speaker/speaker.py @@ -0,0 +1,277 @@ +# Copyright 2021-2022 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# ----------------------------------------------------------------------------- +# Imports +# ----------------------------------------------------------------------------- +import asyncio +import os +import logging + +import click +from bumble.core import BT_BR_EDR_TRANSPORT + +from bumble.device import Device, DeviceConfiguration +from bumble.transport import open_transport +from bumble.avdtp import ( + AVDTP_AUDIO_MEDIA_TYPE, + Listener, + MediaCodecCapabilities, + Protocol, +) +from bumble.a2dp import ( + MPEG_2_AAC_LC_OBJECT_TYPE, + make_audio_sink_service_sdp_records, + A2DP_SBC_CODEC_TYPE, + A2DP_MPEG_2_4_AAC_CODEC_TYPE, + SBC_MONO_CHANNEL_MODE, + SBC_DUAL_CHANNEL_MODE, + SBC_SNR_ALLOCATION_METHOD, + SBC_LOUDNESS_ALLOCATION_METHOD, + SBC_STEREO_CHANNEL_MODE, + SBC_JOINT_STEREO_CHANNEL_MODE, + SbcMediaCodecInformation, + AacMediaCodecInformation +) +from bumble.utils import AsyncRunner + + +# ----------------------------------------------------------------------------- +class Speaker: + def __init__(self, transport, discover): + self.transport = transport + self.discover = discover + self.device = None + self.listener = None + self.output_filename = 'speaker_output.sbc' + self.output = None + + def sdp_records(self): + service_record_handle = 0x00010001 + return { + service_record_handle: make_audio_sink_service_sdp_records( + service_record_handle + ) + } + + def codec_capabilities(self): + return self.aac_codec_capabilities() + + def aac_codec_capabilities(self): + return MediaCodecCapabilities( + media_type=AVDTP_AUDIO_MEDIA_TYPE, + media_codec_type=A2DP_MPEG_2_4_AAC_CODEC_TYPE, + media_codec_information=AacMediaCodecInformation.from_lists( + object_types=[MPEG_2_AAC_LC_OBJECT_TYPE], + sampling_frequencies=[48000, 44100], + channels=[1,2], + vbr=1, + bitrate=256000 + ) + ) + + def sbc_codec_capabilities(self): + return MediaCodecCapabilities( + media_type=AVDTP_AUDIO_MEDIA_TYPE, + media_codec_type=A2DP_SBC_CODEC_TYPE, + media_codec_information=SbcMediaCodecInformation.from_lists( + sampling_frequencies=[48000, 44100, 32000, 16000], + channel_modes=[ + SBC_MONO_CHANNEL_MODE, + SBC_DUAL_CHANNEL_MODE, + SBC_STEREO_CHANNEL_MODE, + SBC_JOINT_STEREO_CHANNEL_MODE, + ], + block_lengths=[4, 8, 12, 16], + subbands=[4, 8], + allocation_methods=[ + SBC_LOUDNESS_ALLOCATION_METHOD, + SBC_SNR_ALLOCATION_METHOD, + ], + minimum_bitpool_value=2, + maximum_bitpool_value=53, + ), + ) + + def on_bluetooth_connection(self, connection): + print(f"Connection: {connection}") + connection.on('disconnection', self.on_bluetooth_disconnection) + + def on_bluetooth_disconnection(self, reason): + print(f"Disconnection ({reason})") + AsyncRunner.spawn(self.advertise()) + + def on_avdtp_connection(self, protocol): + print("Audio Stream Open") + + # Add a sink endpoint to the server + sink = protocol.add_sink(self.codec_capabilities()) + sink.on('start', self.on_sink_start) + sink.on('stop', self.on_sink_stop) + sink.on('suspend', self.on_sink_suspend) + sink.on('configuration', lambda: self.on_sink_configuration(sink.configuration)) + sink.on('rtp_packet', self.on_rtp_packet) + sink.on('rtp_channel_open', self.on_rtp_channel_open) + sink.on('rtp_channel_close', self.on_rtp_channel_close) + + # Listen for close events + protocol.on('close', self.on_avdtp_close) + + # Discover all endpoints on the remote device is requested + if self.discover: + AsyncRunner.spawn(self.discover_remote_endpoints(protocol)) + + def on_avdtp_close(self): + print("Audio Stream Closed") + + def on_sink_start(self): + print("Sink Start") + + def on_sink_stop(self): + print("Sink Stop") + + def on_sink_suspend(self): + print("Sink Suspend") + + def on_sink_configuration(self, config): + print("Sink Configuration:") + print('\n'.join([" " + str(capability) for capability in config])) + + def on_rtp_channel_open(self): + print("RTP Channel Open") + + def on_rtp_channel_close(self): + print("RTP Channel Closed") + + def on_rtp_packet(self, packet): + # header = packet.payload[0] + # fragmented = header >> 7 + # # start = (header >> 6) & 0x01 + # # last = (header >> 5) & 0x01 + # number_of_frames = header & 0x0F + + # payload = packet.payload[1:] + # payload_size = len(payload) + # if fragmented: + # print(f'RTP: fragment {payload_size} bytes in {number_of_frames} frames') + # else: + # print(f'RTP: {payload_size} bytes in {number_of_frames} frames') + print(packet.payload.hex()) + + self.output.write(packet.payload) + + async def advertise(self): + await self.device.set_discoverable(True) + await self.device.set_connectable(True) + + async def connect(self, address): + # Connect to the source + print(f'=== Connecting to {address}...') + connection = await self.device.connect( + address, transport=BT_BR_EDR_TRANSPORT + ) + print(f'=== Connected to {connection.peer_address}') + self.on_bluetooth_connection(connection) + + # Request authentication + print('*** Authenticating...') + await connection.authenticate() + print('*** Authenticated') + + # Enable encryption + print('*** Enabling encryption...') + await connection.encrypt() + print('*** Encryption on') + + protocol = await Protocol.connect(connection) + self.listener.set_server(connection, protocol) + self.on_avdtp_connection(protocol) + + async def discover_remote_endpoints(self, protocol): + endpoints = await protocol.discover_remote_endpoints() + print(f'@@@ Found {len(endpoints)} endpoints') + for endpoint in endpoints: + print('@@@', endpoint) + + async def run(self, connect_address): + async with await open_transport(self.transport) as (hci_source, hci_sink): + with open(self.output_filename, 'wb') as sbc_file: + self.output = sbc_file + + # Create a device + device_config = DeviceConfiguration() + device_config.name = "Bumble Speaker" + device_config.class_of_device = 2360324 + device_config.keystore = "JsonKeyStore" + device_config.classic_enabled = True + device_config.le_enabled = False + self.device = Device.from_config_with_hci( + device_config, hci_source, hci_sink + ) + + # Setup the SDP to expose the sink service + self.device.sdp_service_records = self.sdp_records() + + # Start the controller + await self.device.power_on() + + # Listen for Bluetooth connections + self.device.on('connection', self.on_bluetooth_connection); + + # Create a listener to wait for AVDTP connections + self.listener = Listener(Listener.create_registrar(self.device)) + self.listener.on('connection', self.on_avdtp_connection) + + if connect_address: + # Connect to the source + await self.connect(connect_address) + else: + # Start being discoverable and connectable + await self.advertise() + + await hci_source.wait_for_termination() + + +# ----------------------------------------------------------------------------- +@click.group() +@click.option('--device-config', metavar='FILENAME', help='Device configuration file') +@click.pass_context +def speaker(ctx, device_config): + ctx.ensure_object(dict) + ctx.obj['device_config'] = device_config + + +@speaker.command() +@click.argument('transport') +@click.option( + '--connect', + 'connect_address', + metavar='ADDRESS_OR_NAME', + help='Address or name to connect to', +) +@click.option('--discover', is_flag=True) +@click.pass_context +def play(ctx, transport, connect_address, discover): + asyncio.run(Speaker(transport, discover).run(connect_address)) + + +# ----------------------------------------------------------------------------- +def main(): + logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'INFO').upper()) + speaker() + + +# ----------------------------------------------------------------------------- +if __name__ == "__main__": + main() # pylint: disable=no-value-for-parameter diff --git a/bumble/device.py b/bumble/device.py index 258a43d6..bbcf43d4 100644 --- a/bumble/device.py +++ b/bumble/device.py @@ -948,12 +948,16 @@ class Device(CompositeEventEmitter): config.load_from_file(filename) return cls(config=config) + @classmethod + def from_config_with_hci(cls, config, hci_source, hci_sink): + host = Host(controller_source=hci_source, controller_sink=hci_sink) + return cls(config=config, host=host) + @classmethod def from_config_file_with_hci(cls, filename, hci_source, hci_sink): config = DeviceConfiguration() config.load_from_file(filename) - host = Host(controller_source=hci_source, controller_sink=hci_sink) - return cls(config=config, host=host) + return cls.from_config_with_hci(config, hci_source, hci_sink) def __init__( self, diff --git a/setup.cfg b/setup.cfg index 1644b284..60aca27d 100644 --- a/setup.cfg +++ b/setup.cfg @@ -60,6 +60,7 @@ console_scripts = bumble-usb-probe = bumble.apps.usb_probe:main bumble-link-relay = bumble.apps.link_relay.link_relay:main bumble-bench = bumble.apps.bench:main + bumble-speaker = bumble.apps.speaker.speaker:main [options.package_data] * = py.typed, *.pyi From 7b7ef85b145d27bbe00690955ee22903d78ca38f Mon Sep 17 00:00:00 2001 From: Gilles Boccon-Gibod Date: Fri, 12 May 2023 16:25:46 -0700 Subject: [PATCH 03/15] wip --- apps/speaker/logo.svg | 42 ++++ apps/speaker/speaker.css | 66 +++++ apps/speaker/speaker.html | 33 +++ apps/speaker/speaker.js | 297 +++++++++++++++++++++++ apps/speaker/speaker.py | 492 +++++++++++++++++++++++++++++++++----- bumble/codecs.py | 381 +++++++++++++++++++++++++++++ setup.cfg | 5 +- speaker.html | 28 +++ tests/codecs_test.py | 64 +++++ 9 files changed, 1342 insertions(+), 66 deletions(-) create mode 100644 apps/speaker/logo.svg create mode 100644 apps/speaker/speaker.css create mode 100644 apps/speaker/speaker.html create mode 100644 apps/speaker/speaker.js create mode 100644 bumble/codecs.py create mode 100644 speaker.html create mode 100644 tests/codecs_test.py diff --git a/apps/speaker/logo.svg b/apps/speaker/logo.svg new file mode 100644 index 00000000..70ef7a90 --- /dev/null +++ b/apps/speaker/logo.svg @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/speaker/speaker.css b/apps/speaker/speaker.css new file mode 100644 index 00000000..2d3bcfbf --- /dev/null +++ b/apps/speaker/speaker.css @@ -0,0 +1,66 @@ +body, h1, h2, h3, h4, h5, h6 { + font-family: sans-serif; +} + +#controlsDiv { + margin: 6px; +} + +#connectionText { + background-color: rgb(239, 89, 75); + border: none; + border-radius: 4px; + padding: 8px; + display: inline-block; + margin: 4px; +} + +#startButton { + padding: 4px; + margin: 6px; +} + +#fftCanvas { + border-radius: 16px; + margin: 6px; +} + +#bandwidthCanvas { + border: grey; + border-style: solid; + border-radius: 8px; + margin: 6px; +} + +#streamStateText { + background-color: rgb(93, 165, 93); + border: none; + border-radius: 8px; + padding: 10px 20px; + display: inline-block; + margin: 6px; +} + +#propertiesTable { + border: grey; + border-style: solid; + border-radius: 4px; + padding: 4px; + margin: 6px; +} + +th, td { + padding-left: 8px; + padding-right: 8px; +} + +.properties td:nth-child(even) { + background-color: #D6EEEE; + font-family: monospace; +} + +.properties td:nth-child(odd) { + font-weight: bold; +} + +.properties tr td:nth-child(2) { width: 150px; } \ No newline at end of file diff --git a/apps/speaker/speaker.html b/apps/speaker/speaker.html new file mode 100644 index 00000000..4786aabe --- /dev/null +++ b/apps/speaker/speaker.html @@ -0,0 +1,33 @@ + + + + Bumble Speaker + + + + +

Bumble Virtual Speaker

+
+
+ + + +
+ + + + +
Codec
Packets
Bytes
+
+ Bandwidth Graph +
+ IDLE +
+ + +
+ Audio Frequencies Animation + +
+ + \ No newline at end of file diff --git a/apps/speaker/speaker.js b/apps/speaker/speaker.js new file mode 100644 index 00000000..68094d46 --- /dev/null +++ b/apps/speaker/speaker.js @@ -0,0 +1,297 @@ +(function () { + 'use strict'; + +const channelUrl = ((window.location.protocol === "https:") ? "wss://" : "ws://") + window.location.host + "/channel"; +let channelSocket; +let connectionText; +let codecText; +let packetsReceivedText; +let bytesReceivedText; +let streamStateText; +let controlsDiv; +let audioOnButton; +let mediaSource; +let sourceBuffer; +let audioElement; +let audioContext; +let audioAnalyzer; +let audioFrequencyBinCount; +let audioFrequencyData; +let packetsReceived = 0; +let bytesReceived = 0; +let audioState = "stopped"; +let streamState = "IDLE"; +let audioSupportMessageText; +let fftCanvas; +let fftCanvasContext; +let bandwidthCanvas; +let bandwidthCanvasContext; +let bandwidthBinCount; +let bandwidthBins; + +const FFT_WIDTH = 800; +const FFT_HEIGHT = 256; +const BANDWIDTH_WIDTH = 500; +const BANDWIDTH_HEIGHT = 100; + +function hexToBytes(hex) { + return Uint8Array.from(hex.match(/.{1,2}/g).map((byte) => parseInt(byte, 16))); +} + +function init() { + initUI(); + initMediaSource(); + initAudioElement(); + initAnalyzer(); + + connect(); +} + +function initUI() { + controlsDiv = document.getElementById("controlsDiv"); + controlsDiv.style.visibility = "hidden"; + connectionText = document.getElementById("connectionText"); + audioOnButton = document.getElementById("audioOnButton"); + codecText = document.getElementById("codecText"); + packetsReceivedText = document.getElementById("packetsReceivedText"); + bytesReceivedText = document.getElementById("bytesReceivedText"); + streamStateText = document.getElementById("streamStateText"); + audioSupportMessageText = document.getElementById("audioSupportMessageText"); + + audioOnButton.onclick = () => startAudio(); + + setConnectionText(""); +} + +function initMediaSource() { + mediaSource = new MediaSource(); + mediaSource.onsourceopen = onMediaSourceOpen; + mediaSource.onsourceclose = onMediaSourceClose; + mediaSource.onsourceended = onMediaSourceEnd; +} + +function initAudioElement() { + audioElement = document.getElementById("audio"); + audioElement.src = URL.createObjectURL(mediaSource); + // audioElement.controls = true; +} + +function initAnalyzer() { + fftCanvas = document.getElementById("fftCanvas"); + fftCanvas.width = FFT_WIDTH + fftCanvas.height = FFT_HEIGHT + fftCanvasContext = fftCanvas.getContext('2d'); + fftCanvasContext.fillStyle = "rgb(0, 0, 0)"; + fftCanvasContext.fillRect(0, 0, FFT_WIDTH, FFT_HEIGHT); + + bandwidthCanvas = document.getElementById("bandwidthCanvas"); + bandwidthCanvas.width = BANDWIDTH_WIDTH + bandwidthCanvas.height = BANDWIDTH_HEIGHT + bandwidthCanvasContext = bandwidthCanvas.getContext('2d'); + bandwidthCanvasContext.fillStyle = "rgb(255, 255, 255)"; + bandwidthCanvasContext.fillRect(0, 0, BANDWIDTH_WIDTH, BANDWIDTH_HEIGHT); +} + +function startAnalyzer() { + // FFT + audioContext = new AudioContext(); + audioAnalyzer = audioContext.createAnalyser(); + audioAnalyzer.fftSize = 128; + audioFrequencyBinCount = audioAnalyzer.frequencyBinCount; + audioFrequencyData = new Uint8Array(audioFrequencyBinCount); + const stream = audioElement.captureStream(); + const source = audioContext.createMediaStreamSource(stream); + source.connect(audioAnalyzer); + + // Bandwidth + bandwidthBinCount = BANDWIDTH_WIDTH / 2; + bandwidthBins = []; + + requestAnimationFrame(onAnimationFrame); +} + +function setConnectionText(message) { + connectionText.innerText = message; + if (message.length == 0) { + connectionText.style.display = "none"; + } else { + connectionText.style.display = "inline-block"; + } +} + +function onAnimationFrame() { + // FFT + audioAnalyzer.getByteFrequencyData(audioFrequencyData); + fftCanvasContext.fillStyle = "rgb(0, 0, 0)"; + fftCanvasContext.fillRect(0, 0, FFT_WIDTH, FFT_HEIGHT); + const barCount = audioFrequencyBinCount; + const barWidth = (FFT_WIDTH / audioFrequencyBinCount) - 1; + for (let bar = 0; bar < barCount; bar++) { + const barHeight = audioFrequencyData[bar]; + fftCanvasContext.fillStyle = `rgb(${barHeight / 256 * 200 + 50}, 50, ${50 + 2 * bar})`; + fftCanvasContext.fillRect(bar * (barWidth + 1), FFT_HEIGHT - barHeight, barWidth, barHeight); + } + + // Bandwidth + bandwidthCanvasContext.fillStyle = "rgb(255, 255, 255)"; + bandwidthCanvasContext.fillRect(0, 0, BANDWIDTH_WIDTH, BANDWIDTH_HEIGHT); + bandwidthCanvasContext.fillStyle = `rgb(100, 100, 100)`; + for (let t = 0; t < bandwidthBins.length; t++) { + const lineHeight = (bandwidthBins[t] / 1000) * BANDWIDTH_HEIGHT; + bandwidthCanvasContext.fillRect(t * 2, BANDWIDTH_HEIGHT - lineHeight, 2, lineHeight); + } + + // Display again at the next frame + requestAnimationFrame(onAnimationFrame); +} + +function onMediaSourceOpen() { + console.log(this.readyState); + sourceBuffer = mediaSource.addSourceBuffer("audio/aac"); +} + +function onMediaSourceClose() { + console.log(this.readyState); +} + +function onMediaSourceEnd() { + console.log(this.readyState); +} + +async function startAudio() { + try { + console.log("starting audio..."); + audioOnButton.disabled = true; + audioState = "starting"; + await audioElement.play(); + console.log("audio started"); + audioState = "playing"; + startAnalyzer(); + } catch(error) { + console.error(`play failed: ${error}`); + audioState = "stopped"; + audioOnButton.disabled = false; + } +} + +function onAudioPacket(packet) { + if (audioState == "stopped") { + // Drop the packet, we're not ready to play. + return; + } + + // Queue the audio packet. + sourceBuffer.appendBuffer(packet); + + packetsReceived += 1; + packetsReceivedText.innerText = packetsReceived; + bytesReceived += packet.byteLength; + bytesReceivedText.innerText = bytesReceived; + + bandwidthBins[bandwidthBins.length] = packet.byteLength; + if (bandwidthBins.length > bandwidthBinCount) { + bandwidthBins.shift(); + } +} + +function onChannelOpen() { + console.log('channel OPEN'); + setConnectionText(""); + controlsDiv.style.visibility = "visible"; + + // Handshake with the backend. + sendMessage({ + type: "hello" + }); +} + +function onChannelClose() { + console.log('channel CLOSED'); + setConnectionText("Connection to CLI app closed, restart it and reload this page."); + controlsDiv.style.visibility = "hidden"; +} + +function onChannelError(error) { + console.log(`channel ERROR: ${error}`); + setConnectionText(`Connection to CLI app error ({${error}}), restart it and reload this page.`); + controlsDiv.style.visibility = "hidden"; +} + +function onChannelMessage(message) { + if (typeof message.data === 'string' || message.data instanceof String) { + // JSON message. + const jsonMessage = JSON.parse(message.data); + console.log(`channel MESSAGE: ${message.data}`); + + // Dispatch the message. + const handlerName = `on${jsonMessage.type.charAt(0).toUpperCase()}${jsonMessage.type.slice(1)}Message` + const handler = messageHandlers[handlerName]; + if (handler !== undefined) { + const params = jsonMessage.params; + if (params === undefined) { + params = {}; + } + handler(params); + } else { + console.warn(`unhandled message: ${jsonMessage.type}`) + } + } else { + // BINARY audio data. + onAudioPacket(message.data); + } +} + +function onHelloMessage(params) { + codecText.innerText = params.codec; + if (params.codec != "aac") { + audioOnButton.disabled = true; + audioSupportMessageText.innerText = "Only AAC can be played, audio will be disabled"; + audioSupportMessageText.style.display = "inline-block"; + } else { + audioSupportMessageText.innerText = ""; + audioSupportMessageText.style.display = "none"; + } +} + +function onStartMessage(params) { + streamState = "STARTED"; + streamStateText.innerText = streamState; +} + +function onStopMessage(params) { + streamState = "STOPPED"; + streamStateText.innerText = streamState; +} + +function onSuspendMessage(params) { + streamState = "SUSPENDED"; + streamStateText.innerText = streamState; +} + +function sendMessage(message) { + channelSocket.send(JSON.stringify(message)); +} + +function connect() { + console.log("connecting to CLI app"); + + channelSocket = new WebSocket(channelUrl); + channelSocket.binaryType = "arraybuffer"; + channelSocket.onopen = onChannelOpen; + channelSocket.onclose = onChannelClose; + channelSocket.onerror = onChannelError; + channelSocket.onmessage = onChannelMessage; +} + +const messageHandlers = { + onHelloMessage, + onStartMessage, + onStopMessage, + onSuspendMessage +} + +window.onload = (event) => { + init(); +} + +}()); \ No newline at end of file diff --git a/apps/speaker/speaker.py b/apps/speaker/speaker.py index 67afc3d0..3bf17ddc 100644 --- a/apps/speaker/speaker.py +++ b/apps/speaker/speaker.py @@ -15,19 +15,32 @@ # ----------------------------------------------------------------------------- # Imports # ----------------------------------------------------------------------------- +from __future__ import annotations import asyncio +import asyncio.subprocess +from importlib import resources +import json import os import logging +import pathlib +from typing import Dict, List, Optional +import weakref import click -from bumble.core import BT_BR_EDR_TRANSPORT +import aiohttp +from aiohttp import web +import bumble +from bumble.colors import color +from bumble.core import BT_BR_EDR_TRANSPORT from bumble.device import Device, DeviceConfiguration +from bumble.sdp import ServiceAttribute from bumble.transport import open_transport from bumble.avdtp import ( AVDTP_AUDIO_MEDIA_TYPE, Listener, MediaCodecCapabilities, + MediaPacket, Protocol, ) from bumble.a2dp import ( @@ -42,22 +55,323 @@ from bumble.a2dp import ( SBC_STEREO_CHANNEL_MODE, SBC_JOINT_STEREO_CHANNEL_MODE, SbcMediaCodecInformation, - AacMediaCodecInformation + AacMediaCodecInformation, ) from bumble.utils import AsyncRunner +from bumble.codecs import AacAudioRtpPacket + + +# ----------------------------------------------------------------------------- +# Constants +# ----------------------------------------------------------------------------- +DEFAULT_UI_PORT = 7654 + +# ----------------------------------------------------------------------------- +class AudioExtractor: + @staticmethod + def create(codec: str): + if codec == 'aac': + return AacAudioExtractor() + if codec == 'sbc': + return SbcAudioExtractor() + + def extract_audio(self, packet: MediaPacket) -> bytes: + raise NotImplementedError() + + +# ----------------------------------------------------------------------------- +class AacAudioExtractor: + def extract_audio(self, packet: MediaPacket) -> bytes: + return AacAudioRtpPacket(packet.payload).to_adts() + + +# ----------------------------------------------------------------------------- +class SbcAudioExtractor: + def extract_audio(self, packet: MediaPacket) -> bytes: + # header = packet.payload[0] + # fragmented = header >> 7 + # start = (header >> 6) & 0x01 + # last = (header >> 5) & 0x01 + # number_of_frames = header & 0x0F + + # TODO: support fragmented payloads + return packet.payload[1:] + + +# ----------------------------------------------------------------------------- +class Output: + async def start(self): + pass + + async def stop(self): + pass + + async def suspend(self): + pass + + +# ----------------------------------------------------------------------------- +class FileOutput(Output): + filename: str + codec: str + extractor: AudioExtractor + + def __init__(self, filename, codec): + self.filename = filename + self.codec = codec + self.file = open(filename, 'wb') + self.extractor = AudioExtractor.create(codec) + + def on_rtp_packet(self, packet: MediaPacket) -> None: + self.file.write(self.extractor.extract_audio(packet)) + + +# ----------------------------------------------------------------------------- +class QueuedOutput(Output): + MAX_QUEUE_SIZE = 32768 + + packets: asyncio.Queue + extractor: AudioExtractor + packet_pump_task: Optional[asyncio.Task] + started: bool + + def __init__(self, extractor): + self.extractor = extractor + self.packets = asyncio.Queue() + self.packet_pump_task = None + self.started = False + + async def start(self): + if self.started: + return + + self.packet_pump_task = asyncio.create_task(self.pump_packets()) + + async def pump_packets(self): + while True: + packet = await self.packets.get() + await self.on_audio_packet(packet) + + async def on_audio_packet(self, packet: bytes) -> None: + pass + + def on_rtp_packet(self, packet: MediaPacket) -> None: + if self.packets.qsize() > self.MAX_QUEUE_SIZE: + print("queue full, dropping") + return + + self.packets.put_nowait(self.extractor.extract_audio(packet)) + + +# ----------------------------------------------------------------------------- +class WebSocketOutput(QueuedOutput): + def __init__(self, codec, send_audio, send_message): + super().__init__(AudioExtractor.create(codec)) + self.send_audio = send_audio + self.send_message = send_message + + async def on_audio_packet(self, packet: bytes) -> None: + await self.send_audio(packet) + + async def start(self): + await super().start() + await self.send_message('start') + + async def stop(self): + await super().stop() + await self.send_message('stop') + + async def suspend(self): + await super().suspend() + await self.send_message('suspend') + + +# ----------------------------------------------------------------------------- +class FfplayOutput(QueuedOutput): + MAX_QUEUE_SIZE = 32768 + + subprocess: Optional[asyncio.subprocess.Process] + ffplay_task: Optional[asyncio.Task] + + def __init__(self) -> None: + super().__init__(AacAudioExtractor()) + self.subprocess = None + self.ffplay_task = None + + async def start(self): + if self.started: + return + + super().start() + + self.subprocess = await asyncio.create_subprocess_shell( + 'ffplay -acodec aac pipe:0', + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + + self.ffplay_task = asyncio.create_task(self.monitor_ffplay()) + + async def stop(self): + # TODO + pass + + async def suspend(self): + # TODO + pass + + async def monitor_ffplay(self): + async def read_stream(name, stream): + while True: + data = await stream.read() + print(f'{name}:', data) + + await asyncio.wait( + [ + asyncio.create_task( + read_stream('[ffplay stdout]', self.subprocess.stdout) + ), + asyncio.create_task( + read_stream('[ffplay stderr]', self.subprocess.stderr) + ), + asyncio.create_task(self.subprocess.wait()), + ] + ) + print("FFPLAY done") + + async def on_audio_packet(self, packet): + try: + self.subprocess.stdin.write(packet) + except Exception: + print('!!!! exception while sending audio to ffplay pipe') + + +# ----------------------------------------------------------------------------- +class UiServer: + speaker: Speaker + port: int + + def __init__(self, speaker: Speaker, port: int) -> None: + self.speaker = weakref.ref(speaker) + self.port = port + self.channel_socket = None + + async def start_http(self) -> None: + """Start the UI HTTP server.""" + + app = web.Application() + app.add_routes( + [ + web.get('/', self.get_static), + web.get('/speaker.html', self.get_static), + web.get('/speaker.js', self.get_static), + web.get('/speaker.css', self.get_static), + web.get('/logo.svg', self.get_static), + web.get('/channel', self.get_channel), + ] + ) + + runner = web.AppRunner(app) + await runner.setup() + site = web.TCPSite(runner, 'localhost', self.port) + print('UI HTTP server at ' + color(f'http://127.0.0.1:{self.port}', 'green')) + await site.start() + + async def get_static(self, request): + path = request.path + if path == '/': + path = '/speaker.html' + if path.endswith('.html'): + content_type = 'text/html' + elif path.endswith('.js'): + content_type = 'text/javascript' + elif path.endswith('.css'): + content_type = 'text/css' + elif path.endswith('.svg'): + content_type = 'image/svg+xml' + else: + content_type = 'text/plain' + text = ( + resources.files("bumble.apps.speaker") + .joinpath(pathlib.Path(path).relative_to('/')) + .read_text(encoding="utf-8") + ) + return aiohttp.web.Response(text=text, content_type=content_type) + + async def get_channel(self, request): + ws = web.WebSocketResponse() + await ws.prepare(request) + + # Process messages until the socket is closed. + self.channel_socket = ws + async for message in ws: + if message.type == aiohttp.WSMsgType.TEXT: + print(f'<<< received message: {message.data}') + await self.on_message(message.data) + elif message.type == aiohttp.WSMsgType.ERROR: + print(f'channel connection closed with exception {ws.exception()}') + + self.channel_socket = None + print('--- channel connection closed') + + return ws + + async def on_message(self, message_str: str): + # Parse the message as JSON + message = json.loads(message_str) + + # Dispatch the message + message_type = message['type'] + message_params = message.get('params', {}) + handler = getattr(self, f'on_{message_type}_message') + if handler: + await handler(**message_params) + + async def on_hello_message(self): + print('HELLO') + await self.send_message( + 'hello', bumble_version=bumble.__version__, codec=self.speaker().codec + ) + + async def send_message(self, message_type: str, **kwargs) -> None: + if self.channel_socket is None: + return + + message = {'type': message_type, 'params': kwargs} + await self.channel_socket.send_json(message) + + async def send_audio(self, data: bytes) -> None: + if self.channel_socket is None: + return + + await self.channel_socket.send_bytes(data) # ----------------------------------------------------------------------------- class Speaker: - def __init__(self, transport, discover): + def __init__(self, transport, codec, discover, outputs, ui_port): self.transport = transport + self.codec = codec self.discover = discover + self.ui_port = ui_port self.device = None self.listener = None - self.output_filename = 'speaker_output.sbc' - self.output = None + self.packets_received = 0 + self.bytes_received = 0 + self.outputs = [] + for output in outputs: + if output == '@ffplay': + self.outputs.append(FfplayOutput()) + continue - def sdp_records(self): + # Default to FileOutput + self.outputs.append(FileOutput(output, codec)) + + # Create an HTTP server for the UI + self.ui_server = UiServer(speaker=self, port=ui_port) + + def sdp_records(self) -> Dict[int, List[ServiceAttribute]]: service_record_handle = 0x00010001 return { service_record_handle: make_audio_sink_service_sdp_records( @@ -65,23 +379,29 @@ class Speaker: ) } - def codec_capabilities(self): - return self.aac_codec_capabilities() + def codec_capabilities(self) -> MediaCodecCapabilities: + if self.codec == 'aac': + return self.aac_codec_capabilities() - def aac_codec_capabilities(self): + if self.codec == 'sbc': + return self.sbc_codec_capabilities() + + raise RuntimeError('unsupported codec') + + def aac_codec_capabilities(self) -> MediaCodecCapabilities: return MediaCodecCapabilities( media_type=AVDTP_AUDIO_MEDIA_TYPE, media_codec_type=A2DP_MPEG_2_4_AAC_CODEC_TYPE, media_codec_information=AacMediaCodecInformation.from_lists( object_types=[MPEG_2_AAC_LC_OBJECT_TYPE], sampling_frequencies=[48000, 44100], - channels=[1,2], + channels=[1, 2], vbr=1, - bitrate=256000 - ) + bitrate=256000, + ), ) - def sbc_codec_capabilities(self): + def sbc_codec_capabilities(self) -> MediaCodecCapabilities: return MediaCodecCapabilities( media_type=AVDTP_AUDIO_MEDIA_TYPE, media_codec_type=A2DP_SBC_CODEC_TYPE, @@ -137,12 +457,18 @@ class Speaker: def on_sink_start(self): print("Sink Start") + for output in self.outputs: + AsyncRunner.spawn(output.start()) def on_sink_stop(self): print("Sink Stop") + for output in self.outputs: + AsyncRunner.spawn(output.stop()) def on_sink_suspend(self): print("Sink Suspend") + for output in self.outputs: + AsyncRunner.spawn(output.suspend()) def on_sink_configuration(self, config): print("Sink Configuration:") @@ -155,21 +481,15 @@ class Speaker: print("RTP Channel Closed") def on_rtp_packet(self, packet): - # header = packet.payload[0] - # fragmented = header >> 7 - # # start = (header >> 6) & 0x01 - # # last = (header >> 5) & 0x01 - # number_of_frames = header & 0x0F + self.packets_received += 1 + self.bytes_received += len(packet.payload) + print( + f'[{self.bytes_received} bytes in {self.packets_received} packets] {packet}', + end='\r', + ) - # payload = packet.payload[1:] - # payload_size = len(payload) - # if fragmented: - # print(f'RTP: fragment {payload_size} bytes in {number_of_frames} frames') - # else: - # print(f'RTP: {payload_size} bytes in {number_of_frames} frames') - print(packet.payload.hex()) - - self.output.write(packet.payload) + for output in self.outputs: + output.on_rtp_packet(packet) async def advertise(self): await self.device.set_discoverable(True) @@ -178,9 +498,7 @@ class Speaker: async def connect(self, address): # Connect to the source print(f'=== Connecting to {address}...') - connection = await self.device.connect( - address, transport=BT_BR_EDR_TRANSPORT - ) + connection = await self.device.connect(address, transport=BT_BR_EDR_TRANSPORT) print(f'=== Connected to {connection.peer_address}') self.on_bluetooth_connection(connection) @@ -205,71 +523,117 @@ class Speaker: print('@@@', endpoint) async def run(self, connect_address): + print(f'Speaker ready to play, codec={color(self.codec, "cyan")}') + await self.ui_server.start_http() + self.outputs.append( + WebSocketOutput( + self.codec, self.ui_server.send_audio, self.ui_server.send_message + ) + ) + async with await open_transport(self.transport) as (hci_source, hci_sink): - with open(self.output_filename, 'wb') as sbc_file: - self.output = sbc_file + # Create a device + device_config = DeviceConfiguration() + device_config.name = "Bumble Speaker" + device_config.class_of_device = 0x240404 + device_config.keystore = "JsonKeyStore" + device_config.classic_enabled = True + device_config.le_enabled = False + self.device = Device.from_config_with_hci( + device_config, hci_source, hci_sink + ) - # Create a device - device_config = DeviceConfiguration() - device_config.name = "Bumble Speaker" - device_config.class_of_device = 2360324 - device_config.keystore = "JsonKeyStore" - device_config.classic_enabled = True - device_config.le_enabled = False - self.device = Device.from_config_with_hci( - device_config, hci_source, hci_sink - ) + # Setup the SDP to expose the sink service + self.device.sdp_service_records = self.sdp_records() - # Setup the SDP to expose the sink service - self.device.sdp_service_records = self.sdp_records() + # Start the controller + await self.device.power_on() - # Start the controller - await self.device.power_on() + # Listen for Bluetooth connections + self.device.on('connection', self.on_bluetooth_connection) - # Listen for Bluetooth connections - self.device.on('connection', self.on_bluetooth_connection); + # Create a listener to wait for AVDTP connections + self.listener = Listener(Listener.create_registrar(self.device)) + self.listener.on('connection', self.on_avdtp_connection) - # Create a listener to wait for AVDTP connections - self.listener = Listener(Listener.create_registrar(self.device)) - self.listener.on('connection', self.on_avdtp_connection) + if connect_address: + # Connect to the source + await self.connect(connect_address) + else: + # Start being discoverable and connectable + print("Waiting for connection...") + await self.advertise() - if connect_address: - # Connect to the source - await self.connect(connect_address) - else: - # Start being discoverable and connectable - await self.advertise() + await hci_source.wait_for_termination() - await hci_source.wait_for_termination() + for output in self.outputs: + await output.stop() # ----------------------------------------------------------------------------- @click.group() @click.option('--device-config', metavar='FILENAME', help='Device configuration file') @click.pass_context -def speaker(ctx, device_config): +def speaker_cli(ctx, device_config): ctx.ensure_object(dict) ctx.obj['device_config'] = device_config -@speaker.command() +@speaker_cli.command() @click.argument('transport') +@click.option( + '--codec', type=click.Choice(['sbc', 'aac']), default='aac', show_default=True +) @click.option( '--connect', 'connect_address', metavar='ADDRESS_OR_NAME', help='Address or name to connect to', ) -@click.option('--discover', is_flag=True) +@click.option( + '--discover', is_flag=True, help='Discover remote endpoints once connected' +) +@click.option( + '--output', + multiple=True, + metavar='NAME', + help=( + 'Send audio to this named output ' + '(may be used more than once for multiple outputs)' + ), +) +@click.option( + '--ui-port', + 'ui_port', + metavar='HTTP_PORT', + default=DEFAULT_UI_PORT, + show_default=True, + help='HTTP port for the UI server', +) @click.pass_context -def play(ctx, transport, connect_address, discover): - asyncio.run(Speaker(transport, discover).run(connect_address)) +def play(ctx, transport, codec, connect_address, discover, output, ui_port): + """Run the speaker in playback mode.""" + + # ffplay only works with AAC for now + if codec != 'aac' and '@ffplay' in output: + print( + color( + f'{codec} not supported with @ffplay output, ' + '@ffplay output will be skipped', + 'yellow', + ) + ) + output = list(filter(lambda x: x != '@ffplay', output)) + + asyncio.run( + Speaker(transport, codec, discover, output, ui_port).run(connect_address) + ) # ----------------------------------------------------------------------------- def main(): logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'INFO').upper()) - speaker() + speaker_cli() # ----------------------------------------------------------------------------- diff --git a/bumble/codecs.py b/bumble/codecs.py new file mode 100644 index 00000000..1d7ae82c --- /dev/null +++ b/bumble/codecs.py @@ -0,0 +1,381 @@ +# Copyright 2023 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# ----------------------------------------------------------------------------- +# Imports +# ----------------------------------------------------------------------------- +from __future__ import annotations +from dataclasses import dataclass + + +# ----------------------------------------------------------------------------- +class BitReader: + """Simple but not optimized bit stream reader.""" + + data: bytes + bytes_position: int + bit_position: int + cache: int + bits_cached: int + + def __init__(self, data: bytes): + self.data = data + self.byte_position = 0 + self.bit_position = 0 + self.cache = 0 + self.bits_cached = 0 + + def read(self, bits: int) -> int: + """ "Read up to 32 bits.""" + + if bits > 32: + raise ValueError('maximum read size is 32') + + if self.bits_cached >= bits: + # We have enough bits. + self.bits_cached -= bits + self.bit_position += bits + return (self.cache >> self.bits_cached) & ((1 << bits) - 1) + + # Read more cache, up to 32 bits + feed_bytes = self.data[self.byte_position : self.byte_position + 4] + feed_size = len(feed_bytes) + feed_int = int.from_bytes(feed_bytes, byteorder='big') + if 8 * feed_size + self.bits_cached < bits: + raise ValueError('trying to read past the data') + self.byte_position += feed_size + + # Combine the new cache and the old cache + cache = self.cache & ((1 << self.bits_cached) - 1) + new_bits = bits - self.bits_cached + self.bits_cached = 8 * feed_size - new_bits + result = (feed_int >> self.bits_cached) | (cache << new_bits) + self.cache = feed_int + + self.bit_position += bits + return result + + def read_bytes(self, count: int): + if self.bit_position + 8 * count > 8 * len(self.data): + raise ValueError('not enough data') + + if self.bit_position % 8: + # Not byte aligned + result = bytearray(count) + for i in range(count): + result[i] = self.read(8) + return bytes(result) + + # Byte aligned + self.byte_position = self.bit_position // 8 + self.bits_cached = 0 + self.cache = 0 + offset = self.bit_position // 8 + self.bit_position += 8 * count + return self.data[offset : offset + count] + + def bits_left(self) -> int: + return (8 * len(self.data)) - self.bit_position + + def skip(self, bits: int) -> None: + # Slow, but simple... + while bits: + if bits > 32: + self.read(32) + bits -= 32 + else: + self.read(bits) + break + + +# ----------------------------------------------------------------------------- +class AacAudioRtpPacket: + """AAC payload encapsulated in an RTP packet payload""" + + @staticmethod + def latm_value(reader: BitReader) -> int: + bytes_for_value = reader.read(2) + value = 0 + for _ in range(bytes_for_value + 1): + value = value * 256 + reader.read(8) + return value + + @staticmethod + def program_config_element(reader: BitReader): + raise ValueError('program_config_element not supported') + + @dataclass + class GASpecificConfig: + def __init__( + self, reader: BitReader, channel_configuration: int, audio_object_type: int + ) -> None: + # GASpecificConfig - ISO/EIC 14496-3 Table 4.1 + frame_length_flag = reader.read(1) + depends_on_core_coder = reader.read(1) + if depends_on_core_coder: + self.core_coder_delay = reader.read(14) + extension_flag = reader.read(1) + if not channel_configuration: + AacAudioRtpPacket.program_config_element(reader) + if audio_object_type in (6, 20): + self.layer_nr = reader.read(3) + if extension_flag: + if audio_object_type == 22: + num_of_sub_frame = reader.read(5) + layer_length = reader.read(11) + if audio_object_type in (17, 19, 20, 23): + aac_section_data_resilience_flags = reader.read(1) + aac_scale_factor_data_resilience_flags = reader.read(1) + aac_spectral_data_resilience_flags = reader.read(1) + extension_flag_3 = reader.read(1) + if extension_flag_3 == 1: + raise ValueError('extensionFlag3 == 1 not supported') + + @staticmethod + def audio_object_type(reader: BitReader): + # GetAudioObjectType - ISO/EIC 14496-3 Table 1.16 + audio_object_type = reader.read(5) + if audio_object_type == 31: + audio_object_type = 32 + reader.read(6) + + return audio_object_type + + @dataclass + class AudioSpecificConfig: + audio_object_type: int + sampling_frequency_index: int + sampling_frequency: int + channel_configuration: int + sbr_present_flag: int + ps_present_flag: int + extension_audio_object_type: int + extension_sampling_frequency_index: int + extension_sampling_frequency: int + extension_channel_configuration: int + + SAMPLING_FREQUENCIES = [ + 96000, + 88200, + 64000, + 48000, + 44100, + 32000, + 24000, + 22050, + 16000, + 12000, + 11025, + 8000, + 7350, + ] + + def __init__(self, reader: BitReader) -> None: + # AudioSpecificConfig - ISO/EIC 14496-3 Table 1.15 + self.audio_object_type = AacAudioRtpPacket.audio_object_type(reader) + self.sampling_frequency_index = reader.read(4) + if self.sampling_frequency_index == 0xF: + self.sampling_frequency = reader.read(24) + else: + self.sampling_frequency = self.SAMPLING_FREQUENCIES[ + self.sampling_frequency_index + ] + self.channel_configuration = reader.read(4) + self.sbr_present_flag = -1 + self.ps_present_flag = -1 + if self.audio_object_type in (5, 29): + self.extension_audio_object_type = 5 + self.sbc_present_flag = 1 + if self.audio_object_type == 29: + self.ps_present_flag = 1 + self.extension_sampling_frequency_index = reader.read(4) + if self.extension_sampling_frequency_index == 0xF: + self.extension_sampling_frequency = reader.read(24) + else: + self.extension_sampling_frequency = self.SAMPLING_FREQUENCIES[ + self.extension_sampling_frequency_index + ] + self.audio_object_type = AacAudioRtpPacket.audio_object_type(reader) + if self.audio_object_type == 22: + self.extension_channel_configuration = reader.read(4) + else: + self.extension_audio_object_type = 0 + + if self.audio_object_type in (1, 2, 3, 4, 6, 7, 17, 19, 20, 21, 22, 23): + ga_specific_config = AacAudioRtpPacket.GASpecificConfig( + reader, self.channel_configuration, self.audio_object_type + ) + else: + raise ValueError( + f'audioObjectType {self.audio_object_type} not supported' + ) + + # if self.extension_audio_object_type != 5 and bits_to_decode >= 16: + # sync_extension_type = reader.read(11) + # if sync_extension_type == 0x2B7: + # self.extension_audio_object_type = AacAudioRtpPacket.audio_object_type(reader) + # if self.extension_audio_object_type == 5: + # self.sbr_present_flag = reader.read(1) + # if self.sbr_present_flag: + # self.extension_sampling_frequency_index = reader.read(4) + # if self.extension_sampling_frequency_index == 0xF: + # self.extension_sampling_frequency = reader.read(24) + # else: + # self.extension_sampling_frequency = self.SAMPLING_FREQUENCIES[self.extension_sampling_frequency_index] + # if bits_to_decode >= 12: + # sync_extension_type = reader.read(11) + # if sync_extension_type == 0x548: + # self.ps_present_flag = reader.read(1) + # elif self.extension_audio_object_type == 22: + # self.sbr_present_flag = reader.read(1) + # if self.sbr_present_flag: + # self.extension_sampling_frequency_index = reader.read(4) + # if self.extension_sampling_frequency_index == 0xF: + # self.extension_sampling_frequency = reader.read(24) + # else: + # self.extension_sampling_frequency = self.SAMPLING_FREQUENCIES[self.extension_sampling_frequency_index] + # self.extension_channel_configuration = reader.read(4) + + @dataclass + class StreamMuxConfig: + other_data_present: int + other_data_len_bits: int + audio_specific_config: AacAudioRtpPacket.AudioSpecificConfig + + def __init__(self, reader: BitReader) -> None: + # StreamMuxConfig - ISO/EIC 14496-3 Table 1.42 + audio_mux_version = reader.read(1) + if audio_mux_version == 1: + audio_mux_version_a = reader.read(1) + else: + audio_mux_version_a = 0 + if audio_mux_version_a != 0: + raise ValueError('audioMuxVersionA != 0 not supported') + if audio_mux_version == 1: + tara_buffer_fullness = AacAudioRtpPacket.latm_value(reader) + stream_cnt = 0 + all_streams_same_time_framing = reader.read(1) + num_sub_frames = reader.read(6) + num_program = reader.read(4) + if num_program != 0: + raise ValueError('num_program != 0 not supported') + num_layer = reader.read(3) + if num_layer != 0: + raise ValueError('num_layer != 0 not supported') + if audio_mux_version == 0: + self.audio_specific_config = AacAudioRtpPacket.AudioSpecificConfig( + reader + ) + else: + asc_len = AacAudioRtpPacket.latm_value(reader) + marker = reader.bit_position + self.audio_specific_config = AacAudioRtpPacket.AudioSpecificConfig( + reader + ) + audio_specific_config_len = reader.bit_position - marker + if asc_len < audio_specific_config_len: + raise ValueError('audio_specific_config_len > asc_len') + asc_len -= audio_specific_config_len + reader.skip(asc_len) + frame_length_type = reader.read(3) + if frame_length_type == 0: + latm_buffer_fullness = reader.read(8) + elif frame_length_type == 1: + frame_length = reader.read(9) + else: + raise ValueError(f'frame_length_type {frame_length_type} not supported') + + self.other_data_present = reader.read(1) + if self.other_data_present: + if audio_mux_version == 1: + self.other_data_len_bits = AacAudioRtpPacket.latm_value(reader) + else: + self.other_data_len_bits = 0 + while True: + self.other_data_len_bits *= 256 + other_data_len_esc = reader.read(1) + self.other_data_len_bits += reader.read(8) + if other_data_len_esc == 0: + break + crc_check_present = reader.read(1) + if crc_check_present: + crc_checksum = reader.read(8) + + @dataclass + class AudioMuxElement: + payload: bytes + stream_mux_config: AacAudioRtpPacket.StreamMuxConfig + + def __init__(self, reader: BitReader, mux_config_present: int): + if mux_config_present == 0: + raise ValueError('muxConfigPresent == 0 not supported') + + # AudioMuxElement - ISO/EIC 14496-3 Table 1.41 + use_same_stream_mux = reader.read(1) + if use_same_stream_mux: + raise ValueError('useSameStreamMux == 1 not supported') + self.stream_mux_config = AacAudioRtpPacket.StreamMuxConfig(reader) + + # We only support: + # allStreamsSameTimeFraming == 1 + # audioMuxVersionA == 0, + # numProgram == 0 + # numSubFrames == 0 + # numLayer == 0 + + mux_slot_length_bytes = 0 + while True: + tmp = reader.read(8) + mux_slot_length_bytes += tmp + if tmp != 255: + break + + self.payload = reader.read_bytes(mux_slot_length_bytes) + + if self.stream_mux_config.other_data_present: + reader.skip(self.stream_mux_config.other_data_len_bits) + + # ByteAlign + while reader.bit_position % 8: + reader.read(1) + + def __init__(self, data: bytes) -> None: + # Parse the bit stream + reader = BitReader(data) + self.audio_mux_element = self.AudioMuxElement(reader, mux_config_present=1) + + def to_adts(self): + # pylint: disable=line-too-long + sampling_frequency_index = ( + self.audio_mux_element.stream_mux_config.audio_specific_config.sampling_frequency_index + ) + channel_configuration = ( + self.audio_mux_element.stream_mux_config.audio_specific_config.channel_configuration + ) + frame_size = len(self.audio_mux_element.payload) + return ( + bytes( + [ + 0xFF, + 0xF1, # 0xF9 (MPEG2) + 0x40 + | (sampling_frequency_index << 2) + | (channel_configuration >> 2), + ((channel_configuration & 0x3) << 6) | ((frame_size + 7) >> 11), + ((frame_size + 7) >> 3) & 0xFF, + (((frame_size + 7) << 5) & 0xFF) | 0x1F, + 0xFC, + ] + ) + + self.audio_mux_element.payload + ) diff --git a/setup.cfg b/setup.cfg index 60aca27d..52c08185 100644 --- a/setup.cfg +++ b/setup.cfg @@ -30,21 +30,22 @@ package_dir = bumble.apps = apps include-package-data = True install_requires = + aiohttp >= 22.1.0; platform_system!='Emscripten' appdirs >= 1.4 click >= 7.1.2; platform_system!='Emscripten' cryptography == 35; platform_system!='Emscripten' grpcio >= 1.46; platform_system!='Emscripten' + humanize >= 4.6.0 libusb1 >= 2.0.1; platform_system!='Emscripten' libusb-package == 1.0.26.1; platform_system!='Emscripten' prompt_toolkit >= 3.0.16; platform_system!='Emscripten' + prettytable >= 3.6.0 protobuf >= 3.12.4 pyee >= 8.2.2 pyserial-asyncio >= 0.5; platform_system!='Emscripten' pyserial >= 3.5; platform_system!='Emscripten' pyusb >= 1.2; platform_system!='Emscripten' websockets >= 8.1; platform_system!='Emscripten' - prettytable >= 3.6.0 - humanize >= 4.6.0 [options.entry_points] console_scripts = diff --git a/speaker.html b/speaker.html new file mode 100644 index 00000000..05cc31f8 --- /dev/null +++ b/speaker.html @@ -0,0 +1,28 @@ + + + + Audio WAV Player + + +

Audio WAV Player

+ + + + + diff --git a/tests/codecs_test.py b/tests/codecs_test.py new file mode 100644 index 00000000..faf9df63 --- /dev/null +++ b/tests/codecs_test.py @@ -0,0 +1,64 @@ +# Copyright 2021-2023 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# ----------------------------------------------------------------------------- +# Imports +# ----------------------------------------------------------------------------- +import pytest +from bumble.codecs import AacAudioRtpPacket, BitReader + + +# ----------------------------------------------------------------------------- +def test_reader(): + reader = BitReader(b'') + with pytest.raises(ValueError): + reader.read(1) + + reader = BitReader(b'hello') + with pytest.raises(ValueError): + reader.read(40) + + reader = BitReader(bytes([0xFF])) + assert reader.read(1) == 1 + with pytest.raises(ValueError): + reader.read(10) + + reader = BitReader(bytes([0x78])) + value = 0 + for _ in range(8): + value = (value << 1) | reader.read(1) + assert value == 0x78 + + + data = bytes([x & 0xFF for x in range(66 * 100)]) + reader = BitReader(data) + value = 0 + for _ in range(100): + for bits in range(1, 33): + value = value << bits | reader.read(bits) + assert value == int.from_bytes(data, byteorder='big') + + +def test_aac_rtp(): + # pylint: disable=line-too-long + packet_data = bytes.fromhex('47fc0000b090800300202066000198000de120000000000000000000000000000000000000000000001c') + packet = AacAudioRtpPacket(packet_data) + adts = packet.to_adts() + assert adts == bytes.fromhex('fff1508004fffc2066000198000de120000000000000000000000000000000000000000000001c') + + +# ----------------------------------------------------------------------------- +if __name__ == '__main__': + test_reader() + test_aac_rtp() From 55a01033a060687f440aa19fcd54e9b5c6ac50ef Mon Sep 17 00:00:00 2001 From: Gilles Boccon-Gibod Date: Mon, 15 May 2023 14:29:58 -0700 Subject: [PATCH 04/15] wip --- apps/speaker/speaker.css | 14 ++++- apps/speaker/speaker.html | 1 + apps/speaker/speaker.js | 67 +++++++++++++--------- apps/speaker/speaker.py | 116 +++++++++++++++++++++++++++++--------- bumble/host.py | 8 ++- 5 files changed, 148 insertions(+), 58 deletions(-) diff --git a/apps/speaker/speaker.css b/apps/speaker/speaker.css index 2d3bcfbf..075068b0 100644 --- a/apps/speaker/speaker.css +++ b/apps/speaker/speaker.css @@ -41,17 +41,27 @@ body, h1, h2, h3, h4, h5, h6 { margin: 6px; } +#connectionStateText { + background-color: rgb(112, 146, 206); + border: none; + border-radius: 8px; + padding: 10px 20px; + display: inline-block; + margin: 6px; +} + #propertiesTable { border: grey; border-style: solid; border-radius: 4px; padding: 4px; margin: 6px; + margin-left: 0px; } th, td { - padding-left: 8px; - padding-right: 8px; + padding-left: 6px; + padding-right: 6px; } .properties td:nth-child(even) { diff --git a/apps/speaker/speaker.html b/apps/speaker/speaker.html index 4786aabe..f68abccb 100644 --- a/apps/speaker/speaker.html +++ b/apps/speaker/speaker.html @@ -22,6 +22,7 @@ IDLE + NOT CONNECTED
diff --git a/apps/speaker/speaker.js b/apps/speaker/speaker.js index 68094d46..7010524c 100644 --- a/apps/speaker/speaker.js +++ b/apps/speaker/speaker.js @@ -8,6 +8,7 @@ let codecText; let packetsReceivedText; let bytesReceivedText; let streamStateText; +let connectionStateText; let controlsDiv; let audioOnButton; let mediaSource; @@ -27,7 +28,7 @@ let fftCanvasContext; let bandwidthCanvas; let bandwidthCanvasContext; let bandwidthBinCount; -let bandwidthBins; +let bandwidthBins = []; const FFT_WIDTH = 800; const FFT_HEIGHT = 256; @@ -56,11 +57,14 @@ function initUI() { packetsReceivedText = document.getElementById("packetsReceivedText"); bytesReceivedText = document.getElementById("bytesReceivedText"); streamStateText = document.getElementById("streamStateText"); + connectionStateText = document.getElementById("connectionStateText"); audioSupportMessageText = document.getElementById("audioSupportMessageText"); audioOnButton.onclick = () => startAudio(); setConnectionText(""); + + requestAnimationFrame(onAnimationFrame); } function initMediaSource() { @@ -94,20 +98,20 @@ function initAnalyzer() { function startAnalyzer() { // FFT - audioContext = new AudioContext(); - audioAnalyzer = audioContext.createAnalyser(); - audioAnalyzer.fftSize = 128; - audioFrequencyBinCount = audioAnalyzer.frequencyBinCount; - audioFrequencyData = new Uint8Array(audioFrequencyBinCount); - const stream = audioElement.captureStream(); - const source = audioContext.createMediaStreamSource(stream); - source.connect(audioAnalyzer); + if (audioElement.captureStream !== undefined) { + audioContext = new AudioContext(); + audioAnalyzer = audioContext.createAnalyser(); + audioAnalyzer.fftSize = 128; + audioFrequencyBinCount = audioAnalyzer.frequencyBinCount; + audioFrequencyData = new Uint8Array(audioFrequencyBinCount); + const stream = audioElement.captureStream(); + const source = audioContext.createMediaStreamSource(stream); + source.connect(audioAnalyzer); + } // Bandwidth bandwidthBinCount = BANDWIDTH_WIDTH / 2; bandwidthBins = []; - - requestAnimationFrame(onAnimationFrame); } function setConnectionText(message) { @@ -121,15 +125,17 @@ function setConnectionText(message) { function onAnimationFrame() { // FFT - audioAnalyzer.getByteFrequencyData(audioFrequencyData); - fftCanvasContext.fillStyle = "rgb(0, 0, 0)"; - fftCanvasContext.fillRect(0, 0, FFT_WIDTH, FFT_HEIGHT); - const barCount = audioFrequencyBinCount; - const barWidth = (FFT_WIDTH / audioFrequencyBinCount) - 1; - for (let bar = 0; bar < barCount; bar++) { - const barHeight = audioFrequencyData[bar]; - fftCanvasContext.fillStyle = `rgb(${barHeight / 256 * 200 + 50}, 50, ${50 + 2 * bar})`; - fftCanvasContext.fillRect(bar * (barWidth + 1), FFT_HEIGHT - barHeight, barWidth, barHeight); + if (audioAnalyzer !== undefined) { + audioAnalyzer.getByteFrequencyData(audioFrequencyData); + fftCanvasContext.fillStyle = "rgb(0, 0, 0)"; + fftCanvasContext.fillRect(0, 0, FFT_WIDTH, FFT_HEIGHT); + const barCount = audioFrequencyBinCount; + const barWidth = (FFT_WIDTH / audioFrequencyBinCount) - 1; + for (let bar = 0; bar < barCount; bar++) { + const barHeight = audioFrequencyData[bar]; + fftCanvasContext.fillStyle = `rgb(${barHeight / 256 * 200 + 50}, 50, ${50 + 2 * bar})`; + fftCanvasContext.fillRect(bar * (barWidth + 1), FFT_HEIGHT - barHeight, barWidth, barHeight); + } } // Bandwidth @@ -175,14 +181,11 @@ async function startAudio() { } function onAudioPacket(packet) { - if (audioState == "stopped") { - // Drop the packet, we're not ready to play. - return; + if (audioState != "stopped") { + // Queue the audio packet. + sourceBuffer.appendBuffer(packet); } - // Queue the audio packet. - sourceBuffer.appendBuffer(packet); - packetsReceived += 1; packetsReceivedText.innerText = packetsReceived; bytesReceived += packet.byteLength; @@ -268,6 +271,14 @@ function onSuspendMessage(params) { streamStateText.innerText = streamState; } +function onConnectionMessage(params) { + connectionStateText.innerText = `CONNECTED: ${params.peer_name} (${params.peer_address})`; +} + +function onDisconnectionMessage(params) { + connectionStateText.innerText = "DISCONNECTED"; +} + function sendMessage(message) { channelSocket.send(JSON.stringify(message)); } @@ -287,7 +298,9 @@ const messageHandlers = { onHelloMessage, onStartMessage, onStopMessage, - onSuspendMessage + onSuspendMessage, + onConnectionMessage, + onDisconnectionMessage } window.onload = (event) => { diff --git a/apps/speaker/speaker.py b/apps/speaker/speaker.py index 3bf17ddc..8de2badd 100644 --- a/apps/speaker/speaker.py +++ b/apps/speaker/speaker.py @@ -1,4 +1,4 @@ -# Copyright 2021-2022 Google LLC +# Copyright 2021-2023 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -23,6 +23,7 @@ import json import os import logging import pathlib +import subprocess from typing import Dict, List, Optional import weakref @@ -33,7 +34,7 @@ from aiohttp import web import bumble from bumble.colors import color from bumble.core import BT_BR_EDR_TRANSPORT -from bumble.device import Device, DeviceConfiguration +from bumble.device import Connection, Device, DeviceConfiguration, Peer from bumble.sdp import ServiceAttribute from bumble.transport import open_transport from bumble.avdtp import ( @@ -61,6 +62,12 @@ from bumble.utils import AsyncRunner from bumble.codecs import AacAudioRtpPacket +# ----------------------------------------------------------------------------- +# Logging +# ----------------------------------------------------------------------------- +logger = logging.getLogger(__name__) + + # ----------------------------------------------------------------------------- # Constants # ----------------------------------------------------------------------------- @@ -100,13 +107,22 @@ class SbcAudioExtractor: # ----------------------------------------------------------------------------- class Output: - async def start(self): + async def start(self) -> None: pass - async def stop(self): + async def stop(self) -> None: pass - async def suspend(self): + async def suspend(self) -> None: + pass + + async def on_connection(self, connection: Connection) -> None: + pass + + async def on_disconnection(self, reason: int) -> None: + pass + + def on_rtp_packet(self, packet: MediaPacket) -> None: pass @@ -157,7 +173,7 @@ class QueuedOutput(Output): def on_rtp_packet(self, packet: MediaPacket) -> None: if self.packets.qsize() > self.MAX_QUEUE_SIZE: - print("queue full, dropping") + logger.debug("queue full, dropping") return self.packets.put_nowait(self.extractor.extract_audio(packet)) @@ -170,6 +186,19 @@ class WebSocketOutput(QueuedOutput): self.send_audio = send_audio self.send_message = send_message + async def on_connection(self, connection: Connection) -> None: + await connection.request_remote_name() + peer_name = '' if connection.peer_name is None else connection.peer_name + peer_address = str(connection.peer_address).replace('/P', '') + await self.send_message( + 'connection', + peer_address=peer_address, + peer_name=peer_name, + ) + + async def on_disconnection(self, reason) -> None: + await self.send_message('disconnection') + async def on_audio_packet(self, packet: bytes) -> None: await self.send_audio(packet) @@ -202,7 +231,7 @@ class FfplayOutput(QueuedOutput): if self.started: return - super().start() + await super().start() self.subprocess = await asyncio.create_subprocess_shell( 'ffplay -acodec aac pipe:0', @@ -225,7 +254,7 @@ class FfplayOutput(QueuedOutput): async def read_stream(name, stream): while True: data = await stream.read() - print(f'{name}:', data) + logger.debug(f'{name}:', data) await asyncio.wait( [ @@ -238,13 +267,13 @@ class FfplayOutput(QueuedOutput): asyncio.create_task(self.subprocess.wait()), ] ) - print("FFPLAY done") + logger.debug("FFPLAY done") async def on_audio_packet(self, packet): try: self.subprocess.stdin.write(packet) except Exception: - print('!!!! exception while sending audio to ffplay pipe') + logger.warning('!!!! exception while sending audio to ffplay pipe') # ----------------------------------------------------------------------------- @@ -307,13 +336,15 @@ class UiServer: self.channel_socket = ws async for message in ws: if message.type == aiohttp.WSMsgType.TEXT: - print(f'<<< received message: {message.data}') + logger.debug(f'<<< received message: {message.data}') await self.on_message(message.data) elif message.type == aiohttp.WSMsgType.ERROR: - print(f'channel connection closed with exception {ws.exception()}') + logger.debug( + f'channel connection closed with exception {ws.exception()}' + ) self.channel_socket = None - print('--- channel connection closed') + logger.debug('--- channel connection closed') return ws @@ -329,10 +360,16 @@ class UiServer: await handler(**message_params) async def on_hello_message(self): - print('HELLO') + logger.debug('HELLO') await self.send_message( 'hello', bumble_version=bumble.__version__, codec=self.speaker().codec ) + if connection := self.speaker().connection: + await self.send_message( + 'connection', + peer_address=str(connection.peer_address).replace('/P', ''), + peer_name=connection.peer_name, + ) async def send_message(self, message_type: str, **kwargs) -> None: if self.channel_socket is None: @@ -345,7 +382,10 @@ class UiServer: if self.channel_socket is None: return - await self.channel_socket.send_bytes(data) + try: + await self.channel_socket.send_bytes(data) + except Exception as error: + logger.warning(f'exception while sending audio packet: {error}') # ----------------------------------------------------------------------------- @@ -356,6 +396,7 @@ class Speaker: self.discover = discover self.ui_port = ui_port self.device = None + self.connection = None self.listener = None self.packets_received = 0 self.bytes_received = 0 @@ -424,16 +465,28 @@ class Speaker: ), ) + async def dispatch_to_outputs(self, function): + for output in self.outputs: + await function(output) + def on_bluetooth_connection(self, connection): - print(f"Connection: {connection}") + print(f'Connection: {connection}') + self.connection = connection connection.on('disconnection', self.on_bluetooth_disconnection) + AsyncRunner.spawn( + self.dispatch_to_outputs(lambda output: output.on_connection(connection)) + ) def on_bluetooth_disconnection(self, reason): - print(f"Disconnection ({reason})") + print(f'Disconnection ({reason})') + self.connection = None AsyncRunner.spawn(self.advertise()) + AsyncRunner.spawn( + self.dispatch_to_outputs(lambda output: output.on_disconnection(reason)) + ) def on_avdtp_connection(self, protocol): - print("Audio Stream Open") + print('Audio Stream Open') # Add a sink endpoint to the server sink = protocol.add_sink(self.codec_capabilities()) @@ -456,19 +509,16 @@ class Speaker: print("Audio Stream Closed") def on_sink_start(self): - print("Sink Start") - for output in self.outputs: - AsyncRunner.spawn(output.start()) + print("Sink Started\u001b[0K") + AsyncRunner.spawn(self.dispatch_to_outputs(lambda output: output.start())) def on_sink_stop(self): - print("Sink Stop") - for output in self.outputs: - AsyncRunner.spawn(output.stop()) + print("Sink Stopped\u001b[0K") + AsyncRunner.spawn(self.dispatch_to_outputs(lambda output: output.stop())) def on_sink_suspend(self): - print("Sink Suspend") - for output in self.outputs: - AsyncRunner.spawn(output.suspend()) + print("Sink Suspended\u001b[0K") + AsyncRunner.spawn(self.dispatch_to_outputs(lambda output: output.suspend())) def on_sink_configuration(self, config): print("Sink Configuration:") @@ -625,6 +675,16 @@ def play(ctx, transport, codec, connect_address, discover, output, ui_port): ) output = list(filter(lambda x: x != '@ffplay', output)) + if '@ffplay' in output: + # Check if ffplay is installed + try: + subprocess.run(['ffplay', '-version'], capture_output=True, check=True) + except FileNotFoundError: + print( + color('ffplay not installed, @ffplay output will be disabled', 'yellow') + ) + output = list(filter(lambda x: x != '@ffplay', output)) + asyncio.run( Speaker(transport, codec, discover, output, ui_port).run(connect_address) ) @@ -632,7 +692,7 @@ def play(ctx, transport, codec, connect_address, discover, output, ui_port): # ----------------------------------------------------------------------------- def main(): - logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'INFO').upper()) + logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'WARNING').upper()) speaker_cli() diff --git a/bumble/host.py b/bumble/host.py index afde2ee6..a33efc80 100644 --- a/bumble/host.py +++ b/bumble/host.py @@ -62,6 +62,7 @@ from .hci import ( HCI_Read_Local_Version_Information_Command, HCI_Reset_Command, HCI_Set_Event_Mask_Command, + map_null_terminated_utf8_string, ) from .core import ( BT_BR_EDR_TRANSPORT, @@ -887,7 +888,12 @@ class Host(AbortableEventEmitter): if event.status != HCI_SUCCESS: self.emit('remote_name_failure', event.bd_addr, event.status) else: - self.emit('remote_name', event.bd_addr, event.remote_name) + utf8_name = event.remote_name + terminator = utf8_name.find(0) + if terminator >= 0: + utf8_name = utf8_name[0:terminator] + + self.emit('remote_name', event.bd_addr, utf8_name) def on_hci_remote_host_supported_features_notification_event(self, event): self.emit( From 121b0a6a93bbf755a06688b101df533a0f69b9c3 Mon Sep 17 00:00:00 2001 From: Gilles Boccon-Gibod Date: Tue, 16 May 2023 11:42:15 -0700 Subject: [PATCH 05/15] fix aiohttp version --- setup.cfg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.cfg b/setup.cfg index 52c08185..d630e09f 100644 --- a/setup.cfg +++ b/setup.cfg @@ -30,7 +30,7 @@ package_dir = bumble.apps = apps include-package-data = True install_requires = - aiohttp >= 22.1.0; platform_system!='Emscripten' + aiohttp >= 3.8.4; platform_system!='Emscripten' appdirs >= 1.4 click >= 7.1.2; platform_system!='Emscripten' cryptography == 35; platform_system!='Emscripten' From 371ea074426bf16f576dd4873dbf82af3ad78b39 Mon Sep 17 00:00:00 2001 From: Gilles Boccon-Gibod Date: Fri, 19 May 2023 16:05:21 -0700 Subject: [PATCH 06/15] wip --- apps/speaker/speaker.py | 30 ++++++++++++++++++++++++------ bumble/hci.py | 18 +++++++++++------- 2 files changed, 35 insertions(+), 13 deletions(-) diff --git a/apps/speaker/speaker.py b/apps/speaker/speaker.py index 8de2badd..56282cb6 100644 --- a/apps/speaker/speaker.py +++ b/apps/speaker/speaker.py @@ -390,7 +390,8 @@ class UiServer: # ----------------------------------------------------------------------------- class Speaker: - def __init__(self, transport, codec, discover, outputs, ui_port): + def __init__(self, device_config, transport, codec, discover, outputs, ui_port): + self.device_config = device_config self.transport = transport self.codec = codec self.discover = discover @@ -573,7 +574,6 @@ class Speaker: print('@@@', endpoint) async def run(self, connect_address): - print(f'Speaker ready to play, codec={color(self.codec, "cyan")}') await self.ui_server.start_http() self.outputs.append( WebSocketOutput( @@ -584,9 +584,13 @@ class Speaker: async with await open_transport(self.transport) as (hci_source, hci_sink): # Create a device device_config = DeviceConfiguration() - device_config.name = "Bumble Speaker" - device_config.class_of_device = 0x240404 - device_config.keystore = "JsonKeyStore" + if self.device_config: + device_config.load_from_file(self.device_config) + else: + device_config.name = "Bumble Speaker" + device_config.class_of_device = 0x240404 + device_config.keystore = "JsonKeyStore" + device_config.classic_enabled = True device_config.le_enabled = False self.device = Device.from_config_with_hci( @@ -599,6 +603,16 @@ class Speaker: # Start the controller await self.device.power_on() + # Print some of the config/properties + print("Speaker Name:", color(device_config.name, 'yellow')) + print( + "Speaker Bluetooth Address:", + color( + self.device.public_address.to_string(with_type_qualifier=False), + 'yellow', + ), + ) + # Listen for Bluetooth connections self.device.on('connection', self.on_bluetooth_connection) @@ -606,6 +620,8 @@ class Speaker: self.listener = Listener(Listener.create_registrar(self.device)) self.listener.on('connection', self.on_avdtp_connection) + print(f'Speaker ready to play, codec={color(self.codec, "cyan")}') + if connect_address: # Connect to the source await self.connect(connect_address) @@ -686,7 +702,9 @@ def play(ctx, transport, codec, connect_address, discover, output, ui_port): output = list(filter(lambda x: x != '@ffplay', output)) asyncio.run( - Speaker(transport, codec, discover, output, ui_port).run(connect_address) + Speaker( + ctx.obj['device_config'], transport, codec, discover, output, ui_port + ).run(connect_address) ) diff --git a/bumble/hci.py b/bumble/hci.py index 43494269..ab4dc3ff 100644 --- a/bumble/hci.py +++ b/bumble/hci.py @@ -1795,6 +1795,16 @@ class Address: def to_bytes(self): return self.address_bytes + def to_string(self, with_type_qualifier=True): + ''' + String representation of the address, MSB first, with an optional type + qualifier. + ''' + result = ':'.join([f'{x:02X}' for x in reversed(self.address_bytes)]) + if not with_type_qualifier or not self.is_public: + return result + return result + '/P' + def __bytes__(self): return self.to_bytes() @@ -1808,13 +1818,7 @@ class Address: ) def __str__(self): - ''' - String representation of the address, MSB first - ''' - result = ':'.join([f'{x:02X}' for x in reversed(self.address_bytes)]) - if not self.is_public: - return result - return result + '/P' + return self.to_string() # Predefined address values From c425b87549d7d7767c1df20036550154c178046f Mon Sep 17 00:00:00 2001 From: Gilles Boccon-Gibod Date: Sun, 4 Jun 2023 12:51:16 -0700 Subject: [PATCH 07/15] add doc --- docs/mkdocs/mkdocs.yml | 1 + docs/mkdocs/src/apps_and_tools/index.md | 1 + docs/mkdocs/src/apps_and_tools/speaker.md | 86 ++++++++++++++++++ docs/mkdocs/src/images/speaker_screenshot.png | Bin 0 -> 143796 bytes examples/speaker.json | 5 + 5 files changed, 93 insertions(+) create mode 100644 docs/mkdocs/src/apps_and_tools/speaker.md create mode 100644 docs/mkdocs/src/images/speaker_screenshot.png create mode 100644 examples/speaker.json diff --git a/docs/mkdocs/mkdocs.yml b/docs/mkdocs/mkdocs.yml index 0ddc982b..fde6b40b 100644 --- a/docs/mkdocs/mkdocs.yml +++ b/docs/mkdocs/mkdocs.yml @@ -44,6 +44,7 @@ nav: - Overview: apps_and_tools/index.md - Console: apps_and_tools/console.md - Bench: apps_and_tools/bench.md + - Speaker: apps_and_tools/speaker.md - HCI Bridge: apps_and_tools/hci_bridge.md - Golden Gate Bridge: apps_and_tools/gg_bridge.md - Show: apps_and_tools/show.md diff --git a/docs/mkdocs/src/apps_and_tools/index.md b/docs/mkdocs/src/apps_and_tools/index.md index fe7af564..0c2b4d5e 100644 --- a/docs/mkdocs/src/apps_and_tools/index.md +++ b/docs/mkdocs/src/apps_and_tools/index.md @@ -11,4 +11,5 @@ These include: * [HCI Bridge](hci_bridge.md) - a HCI transport bridge to connect two HCI transports and filter/snoop the HCI packets * [Golden Gate Bridge](gg_bridge.md) - a bridge between GATT and UDP to use with the Golden Gate "stack tool" * [Show](show.md) - Parse a file with HCI packets and print the details of each packet in a human readable form + * [Speaker](speaker.md) - Virtual Bluetooth speaker, with a command line and browser-based UI. * [Link Relay](link_relay.md) - WebSocket relay for virtual RemoteLink instances to communicate with each other. diff --git a/docs/mkdocs/src/apps_and_tools/speaker.md b/docs/mkdocs/src/apps_and_tools/speaker.md new file mode 100644 index 00000000..5569b9d0 --- /dev/null +++ b/docs/mkdocs/src/apps_and_tools/speaker.md @@ -0,0 +1,86 @@ +SPEAKER APP +=========== + +![logo](../images/speaker_screenshot.png){ width=400 height=320 } + +The Speaker app is virtual Bluetooth speaker (A2DP sink). +The app runs as a command-line executable, but also offers an optional simple +web-browser-based user interface. + +# General Usage +You can invoke the app either as `bumble-speaker` when installed as command +from `pip`, or `python3 apps/speaker/speaker.py` when running from a source +distribution. + +``` +Usage: speaker.py [OPTIONS] TRANSPORT + + Run the speaker. + +Options: + --codec [sbc|aac] [default: aac] + --discover Discover remote endpoints once connected + --output NAME Send audio to this named output (may be used more + than once for multiple outputs) + --ui-port HTTP_PORT HTTP port for the UI server [default: 7654] + --connect ADDRESS_OR_NAME Address or name to connect to + --device-config FILENAME Device configuration file + --help Show this message and exit. +``` + +# Connection +By default, the virtual speaker will wait for another device (like a phone or +computer) to connect to it (and possibly pair). Alternatively, the speaker can +be told to initiate a connection to a remote device, using the `--connect` +option. + +# Outputs +The speaker can have one or more outputs. By default, the only output is a text +display on the console, as well as a browser-based user interface if connected. +In addition, a file output can be used, in which case the received audio data is +saved to a specified file. +Finally, if the host computer on which your are running the application has `ffplay` +as an available command line executable, the `@ffplay` output can be selected, in +which case the received audio will be played on the computer's builtin speakers via +a pipe to `ffplay`. (see the [ffplay documentation](https://www.ffmpeg.org/ffplay.html) +for details) + +# Web User Interface +When the speaker app starts, it prints out on the console the local URL at which you +may point a browser (Chrome recommended for full functionality). The console line +specifying the local UI URL will look like: +``` +UI HTTP server at http://127.0.0.1:7654 +``` + +By default, the web UI will show the status of the connection, as well as a realtime +graph of the received audio bandwidth. +In order to also hear the received audio, you need to click the `Audio on` button +(this is due to the fact that most browsers will require some user interface with the +page before granting access to the audio output APIs). + +# Examples + +In the following examples, we use a single USB Bluetooth controllers `usb:0`. Other +transports can be used of course. + +!!! example "Start the speaker and wait for a connection" + ``` + $ bumble-speaker usb:0 + ``` + +!!! example "Start the speaker and save the AAC audio to a file named `audio.aac`." + ``` + $ bumble-speaker --output audio.aac usb:0 + ``` + +!!! example "Start the speaker and save the SBC audio to a file named `audio.sbc`." + ``` + $ bumble-speaker --codec sbc --output audio.sbc usb:0 + ``` + +!!! example "Start the speaker and connect it to a phone at address `B8:7B:C5:05:57:ED`." + ``` + $ bumble-speaker --connect B8:7B:C5:05:57:ED usb:0 + ``` + diff --git a/docs/mkdocs/src/images/speaker_screenshot.png b/docs/mkdocs/src/images/speaker_screenshot.png new file mode 100644 index 0000000000000000000000000000000000000000..fd34880e8a3ba99cf01047444a7bb1bc30e5dbce GIT binary patch literal 143796 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 literal 0 HcmV?d00001 diff --git a/examples/speaker.json b/examples/speaker.json new file mode 100644 index 00000000..61ce80d4 --- /dev/null +++ b/examples/speaker.json @@ -0,0 +1,5 @@ +{ + "name": "Bumble Speaker", + "class_of_device": 2360324, + "keystore": "JsonKeyStore" +} From de706e96719a1d50b482908ea7cede4b78cd8ea7 Mon Sep 17 00:00:00 2001 From: Gilles Boccon-Gibod Date: Sun, 4 Jun 2023 12:52:09 -0700 Subject: [PATCH 08/15] simplify command line --- apps/speaker/speaker.py | 34 ++++++++++++++++++---------------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/apps/speaker/speaker.py b/apps/speaker/speaker.py index 56282cb6..21234344 100644 --- a/apps/speaker/speaker.py +++ b/apps/speaker/speaker.py @@ -35,6 +35,7 @@ import bumble from bumble.colors import color from bumble.core import BT_BR_EDR_TRANSPORT from bumble.device import Connection, Device, DeviceConfiguration, Peer +from bumble.hci import HCI_StatusError from bumble.sdp import ServiceAttribute from bumble.transport import open_transport from bumble.avdtp import ( @@ -187,7 +188,10 @@ class WebSocketOutput(QueuedOutput): self.send_message = send_message async def on_connection(self, connection: Connection) -> None: - await connection.request_remote_name() + try: + await connection.request_remote_name() + except HCI_StatusError: + pass peer_name = '' if connection.peer_name is None else connection.peer_name peer_address = str(connection.peer_address).replace('/P', '') await self.send_message( @@ -551,7 +555,6 @@ class Speaker: print(f'=== Connecting to {address}...') connection = await self.device.connect(address, transport=BT_BR_EDR_TRANSPORT) print(f'=== Connected to {connection.peer_address}') - self.on_bluetooth_connection(connection) # Request authentication print('*** Authenticating...') @@ -638,24 +641,16 @@ class Speaker: # ----------------------------------------------------------------------------- @click.group() -@click.option('--device-config', metavar='FILENAME', help='Device configuration file') @click.pass_context def speaker_cli(ctx, device_config): ctx.ensure_object(dict) ctx.obj['device_config'] = device_config -@speaker_cli.command() -@click.argument('transport') +@click.command() @click.option( '--codec', type=click.Choice(['sbc', 'aac']), default='aac', show_default=True ) -@click.option( - '--connect', - 'connect_address', - metavar='ADDRESS_OR_NAME', - help='Address or name to connect to', -) @click.option( '--discover', is_flag=True, help='Discover remote endpoints once connected' ) @@ -676,9 +671,16 @@ def speaker_cli(ctx, device_config): show_default=True, help='HTTP port for the UI server', ) -@click.pass_context -def play(ctx, transport, codec, connect_address, discover, output, ui_port): - """Run the speaker in playback mode.""" +@click.option( + '--connect', + 'connect_address', + metavar='ADDRESS_OR_NAME', + help='Address or name to connect to', +) +@click.option('--device-config', metavar='FILENAME', help='Device configuration file') +@click.argument('transport') +def speaker(transport, codec, connect_address, discover, output, ui_port, device_config): + """Run the speaker.""" # ffplay only works with AAC for now if codec != 'aac' and '@ffplay' in output: @@ -703,7 +705,7 @@ def play(ctx, transport, codec, connect_address, discover, output, ui_port): asyncio.run( Speaker( - ctx.obj['device_config'], transport, codec, discover, output, ui_port + device_config, transport, codec, discover, output, ui_port ).run(connect_address) ) @@ -711,7 +713,7 @@ def play(ctx, transport, codec, connect_address, discover, output, ui_port): # ----------------------------------------------------------------------------- def main(): logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'WARNING').upper()) - speaker_cli() + speaker() # ----------------------------------------------------------------------------- From 7ec57d6d6a68505cbb6ec43a95df0d9a8849ea09 Mon Sep 17 00:00:00 2001 From: Gilles Boccon-Gibod Date: Sun, 4 Jun 2023 12:52:27 -0700 Subject: [PATCH 09/15] fix typo --- bumble/device.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bumble/device.py b/bumble/device.py index bbcf43d4..c30a30a7 100644 --- a/bumble/device.py +++ b/bumble/device.py @@ -2439,7 +2439,7 @@ class Device(CompositeEventEmitter): if result.status != HCI_COMMAND_STATUS_PENDING: logger.warning( - 'HCI_Set_Connection_Encryption_Command failed: ' + 'HCI_Remote_Name_Request_Command failed: ' f'{HCI_Constant.error_name(result.status)}' ) raise HCI_StatusError(result) From a11879227902ac5c2724f0aab7930cdb7ae4f373 Mon Sep 17 00:00:00 2001 From: Gilles Boccon-Gibod Date: Sun, 4 Jun 2023 13:12:11 -0700 Subject: [PATCH 10/15] fix format --- apps/speaker/speaker.py | 10 ++++++---- tests/codecs_test.py | 9 ++++++--- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/apps/speaker/speaker.py b/apps/speaker/speaker.py index 21234344..434c1df3 100644 --- a/apps/speaker/speaker.py +++ b/apps/speaker/speaker.py @@ -679,7 +679,9 @@ def speaker_cli(ctx, device_config): ) @click.option('--device-config', metavar='FILENAME', help='Device configuration file') @click.argument('transport') -def speaker(transport, codec, connect_address, discover, output, ui_port, device_config): +def speaker( + transport, codec, connect_address, discover, output, ui_port, device_config +): """Run the speaker.""" # ffplay only works with AAC for now @@ -704,9 +706,9 @@ def speaker(transport, codec, connect_address, discover, output, ui_port, device output = list(filter(lambda x: x != '@ffplay', output)) asyncio.run( - Speaker( - device_config, transport, codec, discover, output, ui_port - ).run(connect_address) + Speaker(device_config, transport, codec, discover, output, ui_port).run( + connect_address + ) ) diff --git a/tests/codecs_test.py b/tests/codecs_test.py index faf9df63..b8affada 100644 --- a/tests/codecs_test.py +++ b/tests/codecs_test.py @@ -40,7 +40,6 @@ def test_reader(): value = (value << 1) | reader.read(1) assert value == 0x78 - data = bytes([x & 0xFF for x in range(66 * 100)]) reader = BitReader(data) value = 0 @@ -52,10 +51,14 @@ def test_reader(): def test_aac_rtp(): # pylint: disable=line-too-long - packet_data = bytes.fromhex('47fc0000b090800300202066000198000de120000000000000000000000000000000000000000000001c') + packet_data = bytes.fromhex( + '47fc0000b090800300202066000198000de120000000000000000000000000000000000000000000001c' + ) packet = AacAudioRtpPacket(packet_data) adts = packet.to_adts() - assert adts == bytes.fromhex('fff1508004fffc2066000198000de120000000000000000000000000000000000000000000001c') + assert adts == bytes.fromhex( + 'fff1508004fffc2066000198000de120000000000000000000000000000000000000000000001c' + ) # ----------------------------------------------------------------------------- From ab4390fbde5247c1be4ed9aa405a220567587e90 Mon Sep 17 00:00:00 2001 From: Gilles Boccon-Gibod Date: Sun, 4 Jun 2023 13:17:32 -0700 Subject: [PATCH 11/15] fix weakref type --- apps/speaker/speaker.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/speaker/speaker.py b/apps/speaker/speaker.py index 434c1df3..93bafe25 100644 --- a/apps/speaker/speaker.py +++ b/apps/speaker/speaker.py @@ -282,7 +282,7 @@ class FfplayOutput(QueuedOutput): # ----------------------------------------------------------------------------- class UiServer: - speaker: Speaker + speaker: weakref.ReferenceType[Speaker] port: int def __init__(self, speaker: Speaker, port: int) -> None: From a1327e910b77b23bc91ec9614a8132189f0b8486 Mon Sep 17 00:00:00 2001 From: Gilles Boccon-Gibod Date: Sun, 4 Jun 2023 15:11:13 -0700 Subject: [PATCH 12/15] allow the ui to join late --- apps/speaker/speaker.js | 17 +++++++++++------ apps/speaker/speaker.py | 18 ++++++++++++++++-- 2 files changed, 27 insertions(+), 8 deletions(-) diff --git a/apps/speaker/speaker.js b/apps/speaker/speaker.js index 7010524c..77cb1ff3 100644 --- a/apps/speaker/speaker.js +++ b/apps/speaker/speaker.js @@ -123,6 +123,11 @@ function setConnectionText(message) { } } +function setStreamState(state) { + streamState = state; + streamStateText.innerText = streamState; +} + function onAnimationFrame() { // FFT if (audioAnalyzer !== undefined) { @@ -254,21 +259,21 @@ function onHelloMessage(params) { audioSupportMessageText.innerText = ""; audioSupportMessageText.style.display = "none"; } + if (params.streamState) { + setStreamState(params.streamState); + } } function onStartMessage(params) { - streamState = "STARTED"; - streamStateText.innerText = streamState; + setStreamState("STARTED"); } function onStopMessage(params) { - streamState = "STOPPED"; - streamStateText.innerText = streamState; + setStreamState("STOPPED"); } function onSuspendMessage(params) { - streamState = "SUSPENDED"; - streamStateText.innerText = streamState; + setStreamState("SUSPENDED"); } function onConnectionMessage(params) { diff --git a/apps/speaker/speaker.py b/apps/speaker/speaker.py index 93bafe25..b5482d3d 100644 --- a/apps/speaker/speaker.py +++ b/apps/speaker/speaker.py @@ -19,6 +19,7 @@ from __future__ import annotations import asyncio import asyncio.subprocess from importlib import resources +import enum import json import os import logging @@ -364,9 +365,11 @@ class UiServer: await handler(**message_params) async def on_hello_message(self): - logger.debug('HELLO') await self.send_message( - 'hello', bumble_version=bumble.__version__, codec=self.speaker().codec + 'hello', + bumble_version=bumble.__version__, + codec=self.speaker().codec, + streamState=self.speaker().stream_state.name, ) if connection := self.speaker().connection: await self.send_message( @@ -394,6 +397,12 @@ class UiServer: # ----------------------------------------------------------------------------- class Speaker: + class StreamState(enum.Enum): + IDLE = 0 + STOPPED = 1 + STARTED = 2 + SUSPENDED = 3 + def __init__(self, device_config, transport, codec, discover, outputs, ui_port): self.device_config = device_config self.transport = transport @@ -405,6 +414,7 @@ class Speaker: self.listener = None self.packets_received = 0 self.bytes_received = 0 + self.stream_state = Speaker.StreamState.IDLE self.outputs = [] for output in outputs: if output == '@ffplay': @@ -515,14 +525,17 @@ class Speaker: def on_sink_start(self): print("Sink Started\u001b[0K") + self.stream_state = self.StreamState.STARTED AsyncRunner.spawn(self.dispatch_to_outputs(lambda output: output.start())) def on_sink_stop(self): print("Sink Stopped\u001b[0K") + self.stream_state = self.StreamState.STOPPED AsyncRunner.spawn(self.dispatch_to_outputs(lambda output: output.stop())) def on_sink_suspend(self): print("Sink Suspended\u001b[0K") + self.stream_state = self.StreamState.SUSPENDED AsyncRunner.spawn(self.dispatch_to_outputs(lambda output: output.suspend())) def on_sink_configuration(self, config): @@ -534,6 +547,7 @@ class Speaker: def on_rtp_channel_close(self): print("RTP Channel Closed") + self.stream_state = self.StreamState.IDLE def on_rtp_packet(self, packet): self.packets_received += 1 From 56594a0c2fc5a9321dd3ce5ed8b807a4d6b950e9 Mon Sep 17 00:00:00 2001 From: Gilles Boccon-Gibod Date: Thu, 8 Jun 2023 16:00:56 -0700 Subject: [PATCH 13/15] fix indentiation --- examples/run_classic_connect.py | 108 +++++++++++++++++--------------- 1 file changed, 57 insertions(+), 51 deletions(-) diff --git a/examples/run_classic_connect.py b/examples/run_classic_connect.py index bb46bf75..f8af5cc9 100644 --- a/examples/run_classic_connect.py +++ b/examples/run_classic_connect.py @@ -23,7 +23,7 @@ from bumble.colors import color from bumble.device import Device from bumble.transport import open_transport_or_link -from bumble.core import BT_BR_EDR_TRANSPORT, BT_L2CAP_PROTOCOL_ID +from bumble.core import BT_BR_EDR_TRANSPORT, BT_L2CAP_PROTOCOL_ID, CommandTimeoutError from bumble.sdp import ( Client as SDP_Client, SDP_PUBLIC_BROWSE_ROOT, @@ -48,62 +48,68 @@ async def main(): # Create a device device = Device.from_config_file_with_hci(sys.argv[1], hci_source, hci_sink) device.classic_enabled = True + device.le_enabled = False await device.power_on() - async def connect(target_address): - print(f'=== Connecting to {target_address}...') - connection = await device.connect(target_address, transport=BT_BR_EDR_TRANSPORT) - print(f'=== Connected to {connection.peer_address}!') - - # Connect to the SDP Server - sdp_client = SDP_Client(device) - await sdp_client.connect(connection) - - # List all services in the root browse group - service_record_handles = await sdp_client.search_services( - [SDP_PUBLIC_BROWSE_ROOT] - ) - print(color('\n==================================', 'blue')) - print(color('SERVICES:', 'yellow'), service_record_handles) - - # For each service in the root browse group, get all its attributes - for service_record_handle in service_record_handles: - attributes = await sdp_client.get_attributes( - service_record_handle, [SDP_ALL_ATTRIBUTES_RANGE] - ) - print(color(f'SERVICE {service_record_handle:04X} attributes:', 'yellow')) - for attribute in attributes: - print(' ', attribute.to_string(with_colors=True)) - - # Search for services with an L2CAP service attribute - search_result = await sdp_client.search_attributes( - [BT_L2CAP_PROTOCOL_ID], [SDP_ALL_ATTRIBUTES_RANGE] - ) - print(color('\n==================================', 'blue')) - print(color('SEARCH RESULTS:', 'yellow')) - for attribute_list in search_result: - print(color('SERVICE:', 'green')) - print( - ' ' - + '\n '.join( - [ - attribute.to_string(with_colors=True) - for attribute in attribute_list - ] + async def connect(target_address): + print(f'=== Connecting to {target_address}...') + try: + connection = await device.connect( + target_address, transport=BT_BR_EDR_TRANSPORT ) + except CommandTimeoutError: + print('!!! Connection timed out') + return + print(f'=== Connected to {connection.peer_address}!') + + # Connect to the SDP Server + sdp_client = SDP_Client(device) + await sdp_client.connect(connection) + + # List all services in the root browse group + service_record_handles = await sdp_client.search_services( + [SDP_PUBLIC_BROWSE_ROOT] ) + print(color('\n==================================', 'blue')) + print(color('SERVICES:', 'yellow'), service_record_handles) - await sdp_client.disconnect() - await hci_source.wait_for_termination() + # For each service in the root browse group, get all its attributes + for service_record_handle in service_record_handles: + attributes = await sdp_client.get_attributes( + service_record_handle, [SDP_ALL_ATTRIBUTES_RANGE] + ) + print(color(f'SERVICE {service_record_handle:04X} attributes:', 'yellow')) + for attribute in attributes: + print(' ', attribute.to_string(with_colors=True)) - # Connect to a peer - target_addresses = sys.argv[3:] - await asyncio.wait( - [ - asyncio.create_task(connect(target_address)) - for target_address in target_addresses - ] - ) + # Search for services with an L2CAP service attribute + search_result = await sdp_client.search_attributes( + [BT_L2CAP_PROTOCOL_ID], [SDP_ALL_ATTRIBUTES_RANGE] + ) + print(color('\n==================================', 'blue')) + print(color('SEARCH RESULTS:', 'yellow')) + for attribute_list in search_result: + print(color('SERVICE:', 'green')) + print( + ' ' + + '\n '.join( + [ + attribute.to_string(with_colors=True) + for attribute in attribute_list + ] + ) + ) + + await sdp_client.disconnect() + + # Connect to a peer + target_addresses = sys.argv[3:] + await asyncio.wait( + [ + asyncio.create_task(connect(target_address)) + for target_address in target_addresses + ] + ) # ----------------------------------------------------------------------------- From bd8236a50147cd30fe4d893467beea0606c95de9 Mon Sep 17 00:00:00 2001 From: Gilles Boccon-Gibod Date: Thu, 8 Jun 2023 16:01:36 -0700 Subject: [PATCH 14/15] appear as speaker instead of headset --- apps/speaker/speaker.py | 18 ++++++++++++++---- bumble/hci.py | 2 +- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/apps/speaker/speaker.py b/apps/speaker/speaker.py index b5482d3d..a2907d49 100644 --- a/apps/speaker/speaker.py +++ b/apps/speaker/speaker.py @@ -34,9 +34,10 @@ from aiohttp import web import bumble from bumble.colors import color -from bumble.core import BT_BR_EDR_TRANSPORT -from bumble.device import Connection, Device, DeviceConfiguration, Peer +from bumble.core import BT_BR_EDR_TRANSPORT, CommandTimeoutError +from bumble.device import Connection, Device, DeviceConfiguration from bumble.hci import HCI_StatusError +from bumble.pairing import PairingConfig from bumble.sdp import ServiceAttribute from bumble.transport import open_transport from bumble.avdtp import ( @@ -605,7 +606,7 @@ class Speaker: device_config.load_from_file(self.device_config) else: device_config.name = "Bumble Speaker" - device_config.class_of_device = 0x240404 + device_config.class_of_device = 0x240414 device_config.keystore = "JsonKeyStore" device_config.classic_enabled = True @@ -617,6 +618,11 @@ class Speaker: # Setup the SDP to expose the sink service self.device.sdp_service_records = self.sdp_records() + # Don't require MITM when pairing. + self.device.pairing_config_factory = lambda connection: PairingConfig( + mitm=False + ) + # Start the controller await self.device.power_on() @@ -641,7 +647,11 @@ class Speaker: if connect_address: # Connect to the source - await self.connect(connect_address) + try: + await self.connect(connect_address) + except CommandTimeoutError: + print(color("Connection timed out", "red")) + return else: # Start being discoverable and connectable print("Waiting for connection...") diff --git a/bumble/hci.py b/bumble/hci.py index ab4dc3ff..1916aa35 100644 --- a/bumble/hci.py +++ b/bumble/hci.py @@ -62,7 +62,7 @@ def map_null_terminated_utf8_string(utf8_bytes): try: terminator = utf8_bytes.find(0) if terminator < 0: - return utf8_bytes + terminator = len(utf8_bytes) return utf8_bytes[0:terminator].decode('utf8') except UnicodeDecodeError: return utf8_bytes From b2c635768f0aa835e946422c68ae15cad8a33b18 Mon Sep 17 00:00:00 2001 From: Gilles Boccon-Gibod Date: Fri, 9 Jun 2023 15:54:32 -0700 Subject: [PATCH 15/15] fix format --- examples/run_classic_connect.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/examples/run_classic_connect.py b/examples/run_classic_connect.py index f8af5cc9..3ae6ed8a 100644 --- a/examples/run_classic_connect.py +++ b/examples/run_classic_connect.py @@ -78,7 +78,9 @@ async def main(): attributes = await sdp_client.get_attributes( service_record_handle, [SDP_ALL_ATTRIBUTES_RANGE] ) - print(color(f'SERVICE {service_record_handle:04X} attributes:', 'yellow')) + print( + color(f'SERVICE {service_record_handle:04X} attributes:', 'yellow') + ) for attribute in attributes: print(' ', attribute.to_string(with_colors=True))