This commit is contained in:
Gilles Boccon-Gibod
2023-05-15 14:29:58 -07:00
parent 7b7ef85b14
commit 55a01033a0
5 changed files with 148 additions and 58 deletions

View File

@@ -41,17 +41,27 @@ body, h1, h2, h3, h4, h5, h6 {
margin: 6px; margin: 6px;
} }
#connectionStateText {
background-color: rgb(112, 146, 206);
border: none;
border-radius: 8px;
padding: 10px 20px;
display: inline-block;
margin: 6px;
}
#propertiesTable { #propertiesTable {
border: grey; border: grey;
border-style: solid; border-style: solid;
border-radius: 4px; border-radius: 4px;
padding: 4px; padding: 4px;
margin: 6px; margin: 6px;
margin-left: 0px;
} }
th, td { th, td {
padding-left: 8px; padding-left: 6px;
padding-right: 8px; padding-right: 6px;
} }
.properties td:nth-child(even) { .properties td:nth-child(even) {

View File

@@ -22,6 +22,7 @@
</td> </td>
</tr></table> </tr></table>
<span id="streamStateText">IDLE</span> <span id="streamStateText">IDLE</span>
<span id="connectionStateText">NOT CONNECTED</span>
<div id="controlsDiv"> <div id="controlsDiv">
<button id="audioOnButton">Audio On</button> <button id="audioOnButton">Audio On</button>
<span id="audioSupportMessageText"></span> <span id="audioSupportMessageText"></span>

View File

@@ -8,6 +8,7 @@ let codecText;
let packetsReceivedText; let packetsReceivedText;
let bytesReceivedText; let bytesReceivedText;
let streamStateText; let streamStateText;
let connectionStateText;
let controlsDiv; let controlsDiv;
let audioOnButton; let audioOnButton;
let mediaSource; let mediaSource;
@@ -27,7 +28,7 @@ let fftCanvasContext;
let bandwidthCanvas; let bandwidthCanvas;
let bandwidthCanvasContext; let bandwidthCanvasContext;
let bandwidthBinCount; let bandwidthBinCount;
let bandwidthBins; let bandwidthBins = [];
const FFT_WIDTH = 800; const FFT_WIDTH = 800;
const FFT_HEIGHT = 256; const FFT_HEIGHT = 256;
@@ -56,11 +57,14 @@ function initUI() {
packetsReceivedText = document.getElementById("packetsReceivedText"); packetsReceivedText = document.getElementById("packetsReceivedText");
bytesReceivedText = document.getElementById("bytesReceivedText"); bytesReceivedText = document.getElementById("bytesReceivedText");
streamStateText = document.getElementById("streamStateText"); streamStateText = document.getElementById("streamStateText");
connectionStateText = document.getElementById("connectionStateText");
audioSupportMessageText = document.getElementById("audioSupportMessageText"); audioSupportMessageText = document.getElementById("audioSupportMessageText");
audioOnButton.onclick = () => startAudio(); audioOnButton.onclick = () => startAudio();
setConnectionText(""); setConnectionText("");
requestAnimationFrame(onAnimationFrame);
} }
function initMediaSource() { function initMediaSource() {
@@ -94,6 +98,7 @@ function initAnalyzer() {
function startAnalyzer() { function startAnalyzer() {
// FFT // FFT
if (audioElement.captureStream !== undefined) {
audioContext = new AudioContext(); audioContext = new AudioContext();
audioAnalyzer = audioContext.createAnalyser(); audioAnalyzer = audioContext.createAnalyser();
audioAnalyzer.fftSize = 128; audioAnalyzer.fftSize = 128;
@@ -102,12 +107,11 @@ function startAnalyzer() {
const stream = audioElement.captureStream(); const stream = audioElement.captureStream();
const source = audioContext.createMediaStreamSource(stream); const source = audioContext.createMediaStreamSource(stream);
source.connect(audioAnalyzer); source.connect(audioAnalyzer);
}
// Bandwidth // Bandwidth
bandwidthBinCount = BANDWIDTH_WIDTH / 2; bandwidthBinCount = BANDWIDTH_WIDTH / 2;
bandwidthBins = []; bandwidthBins = [];
requestAnimationFrame(onAnimationFrame);
} }
function setConnectionText(message) { function setConnectionText(message) {
@@ -121,6 +125,7 @@ function setConnectionText(message) {
function onAnimationFrame() { function onAnimationFrame() {
// FFT // FFT
if (audioAnalyzer !== undefined) {
audioAnalyzer.getByteFrequencyData(audioFrequencyData); audioAnalyzer.getByteFrequencyData(audioFrequencyData);
fftCanvasContext.fillStyle = "rgb(0, 0, 0)"; fftCanvasContext.fillStyle = "rgb(0, 0, 0)";
fftCanvasContext.fillRect(0, 0, FFT_WIDTH, FFT_HEIGHT); fftCanvasContext.fillRect(0, 0, FFT_WIDTH, FFT_HEIGHT);
@@ -131,6 +136,7 @@ function onAnimationFrame() {
fftCanvasContext.fillStyle = `rgb(${barHeight / 256 * 200 + 50}, 50, ${50 + 2 * bar})`; fftCanvasContext.fillStyle = `rgb(${barHeight / 256 * 200 + 50}, 50, ${50 + 2 * bar})`;
fftCanvasContext.fillRect(bar * (barWidth + 1), FFT_HEIGHT - barHeight, barWidth, barHeight); fftCanvasContext.fillRect(bar * (barWidth + 1), FFT_HEIGHT - barHeight, barWidth, barHeight);
} }
}
// Bandwidth // Bandwidth
bandwidthCanvasContext.fillStyle = "rgb(255, 255, 255)"; bandwidthCanvasContext.fillStyle = "rgb(255, 255, 255)";
@@ -175,13 +181,10 @@ async function startAudio() {
} }
function onAudioPacket(packet) { function onAudioPacket(packet) {
if (audioState == "stopped") { if (audioState != "stopped") {
// Drop the packet, we're not ready to play.
return;
}
// Queue the audio packet. // Queue the audio packet.
sourceBuffer.appendBuffer(packet); sourceBuffer.appendBuffer(packet);
}
packetsReceived += 1; packetsReceived += 1;
packetsReceivedText.innerText = packetsReceived; packetsReceivedText.innerText = packetsReceived;
@@ -268,6 +271,14 @@ function onSuspendMessage(params) {
streamStateText.innerText = streamState; streamStateText.innerText = streamState;
} }
function onConnectionMessage(params) {
connectionStateText.innerText = `CONNECTED: ${params.peer_name} (${params.peer_address})`;
}
function onDisconnectionMessage(params) {
connectionStateText.innerText = "DISCONNECTED";
}
function sendMessage(message) { function sendMessage(message) {
channelSocket.send(JSON.stringify(message)); channelSocket.send(JSON.stringify(message));
} }
@@ -287,7 +298,9 @@ const messageHandlers = {
onHelloMessage, onHelloMessage,
onStartMessage, onStartMessage,
onStopMessage, onStopMessage,
onSuspendMessage onSuspendMessage,
onConnectionMessage,
onDisconnectionMessage
} }
window.onload = (event) => { window.onload = (event) => {

View File

@@ -1,4 +1,4 @@
# Copyright 2021-2022 Google LLC # Copyright 2021-2023 Google LLC
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
@@ -23,6 +23,7 @@ import json
import os import os
import logging import logging
import pathlib import pathlib
import subprocess
from typing import Dict, List, Optional from typing import Dict, List, Optional
import weakref import weakref
@@ -33,7 +34,7 @@ from aiohttp import web
import bumble import bumble
from bumble.colors import color from bumble.colors import color
from bumble.core import BT_BR_EDR_TRANSPORT 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.sdp import ServiceAttribute
from bumble.transport import open_transport from bumble.transport import open_transport
from bumble.avdtp import ( from bumble.avdtp import (
@@ -61,6 +62,12 @@ from bumble.utils import AsyncRunner
from bumble.codecs import AacAudioRtpPacket from bumble.codecs import AacAudioRtpPacket
# -----------------------------------------------------------------------------
# Logging
# -----------------------------------------------------------------------------
logger = logging.getLogger(__name__)
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Constants # Constants
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@@ -100,13 +107,22 @@ class SbcAudioExtractor:
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
class Output: class Output:
async def start(self): async def start(self) -> None:
pass pass
async def stop(self): async def stop(self) -> None:
pass 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 pass
@@ -157,7 +173,7 @@ class QueuedOutput(Output):
def on_rtp_packet(self, packet: MediaPacket) -> None: def on_rtp_packet(self, packet: MediaPacket) -> None:
if self.packets.qsize() > self.MAX_QUEUE_SIZE: if self.packets.qsize() > self.MAX_QUEUE_SIZE:
print("queue full, dropping") logger.debug("queue full, dropping")
return return
self.packets.put_nowait(self.extractor.extract_audio(packet)) self.packets.put_nowait(self.extractor.extract_audio(packet))
@@ -170,6 +186,19 @@ class WebSocketOutput(QueuedOutput):
self.send_audio = send_audio self.send_audio = send_audio
self.send_message = send_message 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: async def on_audio_packet(self, packet: bytes) -> None:
await self.send_audio(packet) await self.send_audio(packet)
@@ -202,7 +231,7 @@ class FfplayOutput(QueuedOutput):
if self.started: if self.started:
return return
super().start() await super().start()
self.subprocess = await asyncio.create_subprocess_shell( self.subprocess = await asyncio.create_subprocess_shell(
'ffplay -acodec aac pipe:0', 'ffplay -acodec aac pipe:0',
@@ -225,7 +254,7 @@ class FfplayOutput(QueuedOutput):
async def read_stream(name, stream): async def read_stream(name, stream):
while True: while True:
data = await stream.read() data = await stream.read()
print(f'{name}:', data) logger.debug(f'{name}:', data)
await asyncio.wait( await asyncio.wait(
[ [
@@ -238,13 +267,13 @@ class FfplayOutput(QueuedOutput):
asyncio.create_task(self.subprocess.wait()), asyncio.create_task(self.subprocess.wait()),
] ]
) )
print("FFPLAY done") logger.debug("FFPLAY done")
async def on_audio_packet(self, packet): async def on_audio_packet(self, packet):
try: try:
self.subprocess.stdin.write(packet) self.subprocess.stdin.write(packet)
except Exception: 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 self.channel_socket = ws
async for message in ws: async for message in ws:
if message.type == aiohttp.WSMsgType.TEXT: 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) await self.on_message(message.data)
elif message.type == aiohttp.WSMsgType.ERROR: 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 self.channel_socket = None
print('--- channel connection closed') logger.debug('--- channel connection closed')
return ws return ws
@@ -329,10 +360,16 @@ class UiServer:
await handler(**message_params) await handler(**message_params)
async def on_hello_message(self): async def on_hello_message(self):
print('HELLO') logger.debug('HELLO')
await self.send_message( await self.send_message(
'hello', bumble_version=bumble.__version__, codec=self.speaker().codec '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: async def send_message(self, message_type: str, **kwargs) -> None:
if self.channel_socket is None: if self.channel_socket is None:
@@ -345,7 +382,10 @@ class UiServer:
if self.channel_socket is None: if self.channel_socket is None:
return return
try:
await self.channel_socket.send_bytes(data) 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.discover = discover
self.ui_port = ui_port self.ui_port = ui_port
self.device = None self.device = None
self.connection = None
self.listener = None self.listener = None
self.packets_received = 0 self.packets_received = 0
self.bytes_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): def on_bluetooth_connection(self, connection):
print(f"Connection: {connection}") print(f'Connection: {connection}')
self.connection = connection
connection.on('disconnection', self.on_bluetooth_disconnection) 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): def on_bluetooth_disconnection(self, reason):
print(f"Disconnection ({reason})") print(f'Disconnection ({reason})')
self.connection = None
AsyncRunner.spawn(self.advertise()) AsyncRunner.spawn(self.advertise())
AsyncRunner.spawn(
self.dispatch_to_outputs(lambda output: output.on_disconnection(reason))
)
def on_avdtp_connection(self, protocol): def on_avdtp_connection(self, protocol):
print("Audio Stream Open") print('Audio Stream Open')
# Add a sink endpoint to the server # Add a sink endpoint to the server
sink = protocol.add_sink(self.codec_capabilities()) sink = protocol.add_sink(self.codec_capabilities())
@@ -456,19 +509,16 @@ class Speaker:
print("Audio Stream Closed") print("Audio Stream Closed")
def on_sink_start(self): def on_sink_start(self):
print("Sink Start") print("Sink Started\u001b[0K")
for output in self.outputs: AsyncRunner.spawn(self.dispatch_to_outputs(lambda output: output.start()))
AsyncRunner.spawn(output.start())
def on_sink_stop(self): def on_sink_stop(self):
print("Sink Stop") print("Sink Stopped\u001b[0K")
for output in self.outputs: AsyncRunner.spawn(self.dispatch_to_outputs(lambda output: output.stop()))
AsyncRunner.spawn(output.stop())
def on_sink_suspend(self): def on_sink_suspend(self):
print("Sink Suspend") print("Sink Suspended\u001b[0K")
for output in self.outputs: AsyncRunner.spawn(self.dispatch_to_outputs(lambda output: output.suspend()))
AsyncRunner.spawn(output.suspend())
def on_sink_configuration(self, config): def on_sink_configuration(self, config):
print("Sink Configuration:") 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)) 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( asyncio.run(
Speaker(transport, codec, discover, output, ui_port).run(connect_address) 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(): def main():
logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'INFO').upper()) logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'WARNING').upper())
speaker_cli() speaker_cli()

View File

@@ -62,6 +62,7 @@ from .hci import (
HCI_Read_Local_Version_Information_Command, HCI_Read_Local_Version_Information_Command,
HCI_Reset_Command, HCI_Reset_Command,
HCI_Set_Event_Mask_Command, HCI_Set_Event_Mask_Command,
map_null_terminated_utf8_string,
) )
from .core import ( from .core import (
BT_BR_EDR_TRANSPORT, BT_BR_EDR_TRANSPORT,
@@ -887,7 +888,12 @@ class Host(AbortableEventEmitter):
if event.status != HCI_SUCCESS: if event.status != HCI_SUCCESS:
self.emit('remote_name_failure', event.bd_addr, event.status) self.emit('remote_name_failure', event.bd_addr, event.status)
else: 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): def on_hci_remote_host_supported_features_notification_event(self, event):
self.emit( self.emit(