From 55a01033a060687f440aa19fcd54e9b5c6ac50ef Mon Sep 17 00:00:00 2001 From: Gilles Boccon-Gibod Date: Mon, 15 May 2023 14:29:58 -0700 Subject: [PATCH] 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(