feature/multiple_broadcasters (#1)
Add support for multiple broadcasters Reviewed-on: https://gitea.pstruebi.xyz/auracaster/bumble-auracast/pulls/1
This commit was merged in pull request #1.
This commit is contained in:
6
.gitignore
vendored
6
.gitignore
vendored
@@ -4,7 +4,6 @@ coverage/ # Coverage results after running tests with coverage tools
|
|||||||
.dist-info/ # Wheel metadata (use poetry build to handle this)
|
.dist-info/ # Wheel metadata (use poetry build to handle this)
|
||||||
*.egg-info/ # Egg info directory (automatically created by pip)
|
*.egg-info/ # Egg info directory (automatically created by pip)
|
||||||
auracast.egg-info/
|
auracast.egg-info/
|
||||||
.vscode/ # IDE configuration (edit in VS Code)
|
|
||||||
|
|
||||||
# Ignore these file types and extensions
|
# Ignore these file types and extensions
|
||||||
*.pyc # Compiled Python files (.pyc, .pyo are automatically ignored by git)
|
*.pyc # Compiled Python files (.pyc, .pyo are automatically ignored by git)
|
||||||
@@ -16,11 +15,11 @@ venv/
|
|||||||
env/
|
env/
|
||||||
|
|
||||||
# Ignore any IDE configurations or project-specific metadata
|
# Ignore any IDE configurations or project-specific metadata
|
||||||
.vscode/**
|
|
||||||
.pycharm/**
|
.pycharm/**
|
||||||
*.iml
|
*.iml
|
||||||
.project
|
.project
|
||||||
.settings
|
.settings
|
||||||
|
.vscode/settings.json
|
||||||
|
|
||||||
# Ignore test results and logs (adjust to your specific testing framework)
|
# Ignore test results and logs (adjust to your specific testing framework)
|
||||||
/testresults/**
|
/testresults/**
|
||||||
@@ -35,4 +34,5 @@ env/
|
|||||||
__pycache__/
|
__pycache__/
|
||||||
|
|
||||||
# Exclude .env file from all platforms
|
# Exclude .env file from all platforms
|
||||||
*/.env
|
*/.env
|
||||||
|
|
||||||
|
|||||||
19
.vscode/launch.json
vendored
Normal file
19
.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
// Use IntelliSense to learn about possible attributes.
|
||||||
|
// Hover to view descriptions of existing attributes.
|
||||||
|
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||||
|
"version": "0.2.0",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "Python Debugger: current file",
|
||||||
|
"type": "debugpy",
|
||||||
|
"request": "launch",
|
||||||
|
"program": "${file}",
|
||||||
|
"console": "integratedTerminal",
|
||||||
|
"justMyCode": true,
|
||||||
|
"args": [
|
||||||
|
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
12
.vscode/tasks.json
vendored
Normal file
12
.vscode/tasks.json
vendored
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
// See https://go.microsoft.com/fwlink/?LinkId=733558
|
||||||
|
// for the documentation about the tasks.json format
|
||||||
|
"version": "2.0.0",
|
||||||
|
"tasks": [
|
||||||
|
{
|
||||||
|
"label": "pip install -e bumble",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "./venv/bin/python -m pip install -e ../bumble --config-settings editable_mode=compat"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
BIN
auracast/announcement_48_10_96000_en_stereo.wav
Normal file
BIN
auracast/announcement_48_10_96000_en_stereo.wav
Normal file
Binary file not shown.
BIN
auracast/announcement_48_10_96_es.wav
Normal file
BIN
auracast/announcement_48_10_96_es.wav
Normal file
Binary file not shown.
BIN
auracast/announcement_48_10_96_it.wav
Normal file
BIN
auracast/announcement_48_10_96_it.wav
Normal file
Binary file not shown.
@@ -1,269 +0,0 @@
|
|||||||
# Copyright 2024 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
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import contextlib
|
|
||||||
import logging
|
|
||||||
import wave
|
|
||||||
import itertools
|
|
||||||
from typing import cast, Any, AsyncGenerator, Coroutine, Dict, Optional, Tuple
|
|
||||||
from typing import List
|
|
||||||
|
|
||||||
|
|
||||||
try:
|
|
||||||
import lc3 # type: ignore # pylint: disable=E0401
|
|
||||||
except ImportError as e:
|
|
||||||
raise ImportError("Try `python -m pip install \".[lc3]\"`.") from e
|
|
||||||
|
|
||||||
from bumble.colors import color
|
|
||||||
from bumble import company_ids
|
|
||||||
from bumble import core
|
|
||||||
from bumble import gatt
|
|
||||||
from bumble import hci
|
|
||||||
from bumble.profiles import bap
|
|
||||||
from bumble.profiles import le_audio
|
|
||||||
from bumble.profiles import pbp
|
|
||||||
from bumble.profiles import bass
|
|
||||||
import bumble.device
|
|
||||||
import bumble.transport
|
|
||||||
import bumble.utils
|
|
||||||
from bumble.device import Host
|
|
||||||
|
|
||||||
import auracast_config
|
|
||||||
|
|
||||||
|
|
||||||
def modified_on_hci_number_of_completed_packets_event(self, event):
|
|
||||||
for connection_handle, num_completed_packets in zip(
|
|
||||||
event.connection_handles, event.num_completed_packets
|
|
||||||
):
|
|
||||||
if connection := self.connections.get(connection_handle):
|
|
||||||
connection.acl_packet_queue.on_packets_completed(num_completed_packets)
|
|
||||||
elif connection_handle not in itertools.chain(
|
|
||||||
self.cis_links.keys(),
|
|
||||||
self.sco_links.keys(),
|
|
||||||
itertools.chain.from_iterable(self.bigs.values()),
|
|
||||||
):
|
|
||||||
logger.warning(
|
|
||||||
'received packet completion event for unknown handle '
|
|
||||||
f'0x{connection_handle:04X}'
|
|
||||||
)
|
|
||||||
self.emit('hci_number_of_completed_packets_event', event)
|
|
||||||
|
|
||||||
Host.on_hci_number_of_completed_packets_event = modified_on_hci_number_of_completed_packets_event
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
|
||||||
# Logging
|
|
||||||
# -----------------------------------------------------------------------------
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
@contextlib.asynccontextmanager
|
|
||||||
async def create_device(config: auracast_config.AuracastGlobalConfig) -> AsyncGenerator[bumble.device.Device, Any]:
|
|
||||||
async with await bumble.transport.open_transport(config.transport) as (
|
|
||||||
hci_source,
|
|
||||||
hci_sink,
|
|
||||||
):
|
|
||||||
device_config = bumble.device.DeviceConfiguration(
|
|
||||||
name=config.device_name,
|
|
||||||
address=config.auracast_device_address,
|
|
||||||
keystore='JsonKeyStore',
|
|
||||||
)
|
|
||||||
|
|
||||||
device = bumble.device.Device.from_config_with_hci(
|
|
||||||
device_config,
|
|
||||||
hci_source,
|
|
||||||
hci_sink,
|
|
||||||
)
|
|
||||||
await device.power_on()
|
|
||||||
|
|
||||||
yield device
|
|
||||||
|
|
||||||
|
|
||||||
def run_async(async_command: Coroutine) -> None:
|
|
||||||
try:
|
|
||||||
asyncio.run(async_command)
|
|
||||||
except core.ProtocolError as error:
|
|
||||||
if error.error_namespace == 'att' and error.error_code in list(
|
|
||||||
bass.ApplicationError
|
|
||||||
):
|
|
||||||
message = bass.ApplicationError(error.error_code).name
|
|
||||||
else:
|
|
||||||
message = str(error)
|
|
||||||
|
|
||||||
print(
|
|
||||||
color('!!! An error occurred while executing the command:', 'red'), message
|
|
||||||
)
|
|
||||||
|
|
||||||
async def run_broadcast(
|
|
||||||
global_config : auracast_config.AuracastGlobalConfig,
|
|
||||||
big_config: List[auracast_config.AuracastBigConfig]
|
|
||||||
|
|
||||||
) -> None:
|
|
||||||
async with create_device(global_config) as device:
|
|
||||||
if not device.supports_le_periodic_advertising:
|
|
||||||
logger.error(color('Periodic advertising not supported', 'red'))
|
|
||||||
return
|
|
||||||
|
|
||||||
with wave.open(big_config[0].broacast_wav_file_path, 'rb') as wav:
|
|
||||||
logger.info('Encoding wav file into lc3...')
|
|
||||||
logger.info('Frame rate of .wav file is: %s', wav.getframerate())
|
|
||||||
encoder = lc3.Encoder(
|
|
||||||
frame_duration_us=global_config.frame_duration_us,
|
|
||||||
sample_rate_hz=global_config.auracast_sampling_rate_khz,
|
|
||||||
num_channels=1,
|
|
||||||
input_sample_rate_hz=wav.getframerate(),
|
|
||||||
)
|
|
||||||
frames = list[bytes]()
|
|
||||||
while pcm := wav.readframes(encoder.get_frame_samples()):
|
|
||||||
frames.append(
|
|
||||||
encoder.encode(pcm, num_bytes=global_config.octets_per_frame, bit_depth=wav.getsampwidth() * 8)
|
|
||||||
)
|
|
||||||
del encoder
|
|
||||||
print('Encoding complete.')
|
|
||||||
|
|
||||||
# Config advertising set
|
|
||||||
bap_sampling_freq = getattr(bap.SamplingFrequency, f"FREQ_{global_config.auracast_sampling_rate_khz}")
|
|
||||||
basic_audio_announcement = bap.BasicAudioAnnouncement(
|
|
||||||
presentation_delay=global_config.presentation_delay_us,
|
|
||||||
subgroups=[
|
|
||||||
bap.BasicAudioAnnouncement.Subgroup(
|
|
||||||
codec_id=hci.CodingFormat(codec_id=hci.CodecID.LC3),
|
|
||||||
codec_specific_configuration=bap.CodecSpecificConfiguration(
|
|
||||||
sampling_frequency=bap_sampling_freq,
|
|
||||||
frame_duration=bap.FrameDuration.DURATION_10000_US,
|
|
||||||
octets_per_codec_frame=global_config.octets_per_frame,
|
|
||||||
),
|
|
||||||
metadata=le_audio.Metadata(
|
|
||||||
[
|
|
||||||
le_audio.Metadata.Entry(
|
|
||||||
tag=le_audio.Metadata.Tag.LANGUAGE, data=big_config[0].broadcast_language.encode()
|
|
||||||
),
|
|
||||||
le_audio.Metadata.Entry(
|
|
||||||
tag=le_audio.Metadata.Tag.PROGRAM_INFO, data=big_config[0].broadcast_program_info.encode()
|
|
||||||
),
|
|
||||||
]
|
|
||||||
),
|
|
||||||
bis=[
|
|
||||||
bap.BasicAudioAnnouncement.BIS(
|
|
||||||
index=1,
|
|
||||||
codec_specific_configuration=bap.CodecSpecificConfiguration(
|
|
||||||
audio_channel_allocation=bap.AudioLocation.FRONT_LEFT
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
],
|
|
||||||
)
|
|
||||||
logging.info('Setup Advertising')
|
|
||||||
broadcast_audio_announcement = bap.BroadcastAudioAnnouncement(big_config[0].broadcast_id)
|
|
||||||
advertising_set0 = await device.create_advertising_set(
|
|
||||||
advertising_parameters=bumble.device.AdvertisingParameters(
|
|
||||||
advertising_event_properties=bumble.device.AdvertisingEventProperties(
|
|
||||||
is_connectable=False
|
|
||||||
),
|
|
||||||
primary_advertising_interval_min=100,
|
|
||||||
primary_advertising_interval_max=200,
|
|
||||||
advertising_sid=0
|
|
||||||
# TODO: use 2mbit phy
|
|
||||||
),
|
|
||||||
advertising_data=(
|
|
||||||
broadcast_audio_announcement.get_advertising_data()
|
|
||||||
+ bytes(
|
|
||||||
core.AdvertisingData(
|
|
||||||
[(core.AdvertisingData.BROADCAST_NAME, big_config[0].broadcast_name.encode())]
|
|
||||||
)
|
|
||||||
)
|
|
||||||
),
|
|
||||||
periodic_advertising_parameters=bumble.device.PeriodicAdvertisingParameters(
|
|
||||||
periodic_advertising_interval_min=80,
|
|
||||||
periodic_advertising_interval_max=160,
|
|
||||||
),
|
|
||||||
periodic_advertising_data=basic_audio_announcement.get_advertising_data(),
|
|
||||||
auto_restart=True,
|
|
||||||
auto_start=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
logging.info('Start Periodic Advertising')
|
|
||||||
await advertising_set0.start_periodic()
|
|
||||||
|
|
||||||
logging.info('Setup BIG')
|
|
||||||
big0 = await device.create_big(
|
|
||||||
advertising_set0,
|
|
||||||
parameters=bumble.device.BigParameters(
|
|
||||||
num_bis=1,
|
|
||||||
sdu_interval=global_config.frame_duration_us,
|
|
||||||
max_sdu=global_config.octets_per_frame, # is this octets per frame ?
|
|
||||||
max_transport_latency=65,
|
|
||||||
rtn=4,
|
|
||||||
broadcast_code=(
|
|
||||||
bytes.fromhex(big_config[0].broadcast_code) if big_config[0].broadcast_code else None
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
logging.info('Setup ISO Data Path')
|
|
||||||
for bis_link in big0.bis_links:
|
|
||||||
await bis_link.setup_data_path(
|
|
||||||
direction=bis_link.Direction.HOST_TO_CONTROLLER
|
|
||||||
)
|
|
||||||
|
|
||||||
frames_iterator = itertools.cycle(frames)
|
|
||||||
logging.info("Broadcasting...")
|
|
||||||
|
|
||||||
def on_packet_complete(event):
|
|
||||||
frame = next(frames_iterator)
|
|
||||||
big0.bis_links[0].write(frame)
|
|
||||||
|
|
||||||
device.host.on('hci_number_of_completed_packets_event', on_packet_complete)
|
|
||||||
|
|
||||||
on_packet_complete('') # Send the first packet, to get the event loop running
|
|
||||||
|
|
||||||
while True:
|
|
||||||
await asyncio.sleep(1)
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
|
||||||
# Main
|
|
||||||
# -----------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def broadcast(global_conf: auracast_config.AuracastGlobalConfig, big_conf: List[auracast_config.AuracastBigConfig]):
|
|
||||||
"""Start a broadcast as a source."""
|
|
||||||
run_async(
|
|
||||||
run_broadcast(
|
|
||||||
global_conf,
|
|
||||||
big_conf
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
|
||||||
if __name__ == "__main__":
|
|
||||||
logging.basicConfig(level=logging.INFO)
|
|
||||||
|
|
||||||
global_conf = auracast_config.global_base_config
|
|
||||||
bigs = [
|
|
||||||
auracast_config.broadcast_de
|
|
||||||
]
|
|
||||||
global_conf.octets_per_frame=60# 48kbps@24kHz
|
|
||||||
|
|
||||||
broadcast(
|
|
||||||
global_conf,
|
|
||||||
bigs
|
|
||||||
)
|
|
||||||
@@ -3,46 +3,103 @@ from bumble.profiles import bap
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
# Define some base dataclasses to hold the relevant parameters
|
# Define some base dataclasses to hold the relevant parameters
|
||||||
|
@dataclass
|
||||||
|
class AuracastQoSConfig:
|
||||||
|
iso_int_multiple_10ms: int = 1
|
||||||
|
number_of_retransmissions:int = 4 #4
|
||||||
|
max_transport_latency_ms:int = 43 #varies from the default value in bumble (was 65)
|
||||||
|
|
||||||
|
qos_config_mono_high_rel = AuracastQoSConfig() #highest rel + lowest latency
|
||||||
|
qos_config_mono_medium_rel = AuracastQoSConfig(
|
||||||
|
iso_int_multiple_10ms = 2,
|
||||||
|
number_of_retransmissions = 3,
|
||||||
|
max_transport_latency_ms = 65
|
||||||
|
)
|
||||||
|
qos_config_mono_low_rel = AuracastQoSConfig( #highest latency
|
||||||
|
iso_int_multiple_10ms = 3,
|
||||||
|
number_of_retransmissions = 2,
|
||||||
|
max_transport_latency_ms = 65
|
||||||
|
)
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class AuracastGlobalConfig:
|
class AuracastGlobalConfig:
|
||||||
|
qos_config: AuracastQoSConfig
|
||||||
|
debug: bool = False
|
||||||
device_name: str = 'Auracaster'
|
device_name: str = 'Auracaster'
|
||||||
transport: str = 'serial:/dev/ttyACM1,1000000,rtscts'
|
transport: str = ''
|
||||||
auracast_device_address: hci.Address = hci.Address('F0:F1:F2:F3:F4:F5')
|
auracast_device_address: hci.Address = hci.Address('F0:F1:F2:F3:F4:F5')
|
||||||
auracast_sampling_rate_khz: int =24000
|
auracast_sampling_rate_hz: int = 16000
|
||||||
octets_per_frame: int = 100 # bitrate = octets_per_frame * 8 / frame len
|
octets_per_frame: int = 40 #48kbps@24kHz # bitrate = octets_per_frame * 8 / frame len
|
||||||
frame_duration_us: int = 10000
|
frame_duration_us: int = 10000
|
||||||
presentation_delay_us: int = 40000
|
presentation_delay_us: int = 40000
|
||||||
|
manufacturer_data: tuple[int, bytes] = None
|
||||||
|
|
||||||
|
global_base_config = AuracastGlobalConfig(qos_config=AuracastQoSConfig())
|
||||||
|
|
||||||
|
# "Audio input. "
|
||||||
|
# "'device' -> use the host's default sound input device, "
|
||||||
|
# "'device:<DEVICE_ID>' -> use one of the host's sound input devices "
|
||||||
|
# "(specify 'device:?' to get a list of available sound input devices), "
|
||||||
|
# "'stdin' -> receive audio from stdin as int16 PCM, "
|
||||||
|
# "'file:<filename> -> read audio from a .wav or raw int16 PCM file. "
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class AuracastBigConfig:
|
class AuracastBigConfig:
|
||||||
broadcast_id: int =123456
|
id: int = 123456,
|
||||||
broadcast_code: str = None # a hexstr
|
random_address: hci.Address = hci.Address('F1:F1:F2:F3:F4:F5')
|
||||||
broadcast_language: str = 'en'
|
code: str = None # Broadcast_Code – a 16-octet parameter provided by the Host
|
||||||
broadcast_name: str = 'Broadcast0'
|
language: str = 'eng' # See: https://en.wikipedia.org/wiki/List_of_ISO_639_language_codes
|
||||||
broadcast_program_info: str = 'Some Announcements'
|
name: str = 'Broadcast0'
|
||||||
broacast_wav_file_path: str = './auracast/announcement_48_10_96000_en.wav'
|
program_info: str = 'Some Announcements'
|
||||||
|
audio_source: str = 'file:./auracast/announcement_48_10_96000_en.wav'
|
||||||
|
input_format: str = 'auto'
|
||||||
|
loop_wav: bool = True
|
||||||
|
iso_que_len: int = 64
|
||||||
|
|
||||||
global_base_config = AuracastGlobalConfig()
|
|
||||||
|
|
||||||
# Instanciate some example configurations
|
# Instanciate some example configurations
|
||||||
broadcast_de = AuracastBigConfig(
|
broadcast_de = AuracastBigConfig(
|
||||||
broadcast_id=12,
|
id=12,
|
||||||
broadcast_language='de',
|
random_address=hci.Address('F1:F1:F2:F3:F4:F5'),
|
||||||
broadcast_name = 'Broadcast0',
|
name = 'Broadcast0',
|
||||||
broacast_wav_file_path = './auracast/announcement_48_10_96000_de.wav',
|
language='deu',
|
||||||
|
program_info = 'Announcements German',
|
||||||
|
audio_source = 'file:./auracast/announcement_48_10_96000_de.wav',
|
||||||
)
|
)
|
||||||
|
|
||||||
broadcast_en = AuracastBigConfig(
|
broadcast_en = AuracastBigConfig(
|
||||||
broadcast_id=123,
|
id=123,
|
||||||
broadcast_language='eng',
|
random_address=hci.Address('F2:F1:F2:F3:F4:F5'),
|
||||||
broadcast_name = 'Broadcast1',
|
name = 'Broadcast1',
|
||||||
broacast_wav_file_path = './auracast/announcement_48_10_96000_en.wav',
|
language='eng',
|
||||||
|
program_info = 'Announcements English',
|
||||||
|
audio_source = 'file:./auracast/announcement_48_10_96000_en.wav',
|
||||||
)
|
)
|
||||||
|
|
||||||
broadcast_fr = AuracastBigConfig(
|
broadcast_fr = AuracastBigConfig(
|
||||||
broadcast_id=1234,
|
id=1234,
|
||||||
broadcast_language='fr',
|
random_address=hci.Address('F3:F1:F2:F3:F4:F5'),
|
||||||
broadcast_name = 'Broadcast2',
|
name = 'Broadcast2',
|
||||||
broacast_wav_file_path = './auracast/announcement_48_10_96000_fr.wav',
|
language='fra',
|
||||||
|
program_info = 'Announcements French',
|
||||||
|
audio_source = 'file:./auracast/announcement_48_10_96000_fr.wav',
|
||||||
|
)
|
||||||
|
|
||||||
|
broadcast_es = AuracastBigConfig(
|
||||||
|
id=12345,
|
||||||
|
random_address=hci.Address('F4:F1:F2:F3:F4:F5'),
|
||||||
|
name = 'Broadcast3',
|
||||||
|
language='spa',
|
||||||
|
program_info = 'Announcements Spanish',
|
||||||
|
audio_source = 'file:./auracast/announcement_48_10_96_es.wav',
|
||||||
|
)
|
||||||
|
|
||||||
|
broadcast_it = AuracastBigConfig(
|
||||||
|
id=123456,
|
||||||
|
random_address=hci.Address('F5:F1:F2:F3:F4:F5'),
|
||||||
|
name = 'Broadcast4',
|
||||||
|
language='ita',
|
||||||
|
program_info = 'Announcements Italian',
|
||||||
|
audio_source = 'file:./auracast/announcement_48_10_96_it.wav',
|
||||||
)
|
)
|
||||||
459
auracast/multicast.py
Normal file
459
auracast/multicast.py
Normal file
@@ -0,0 +1,459 @@
|
|||||||
|
# Copyright 2024 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
|
||||||
|
import pprint
|
||||||
|
import asyncio
|
||||||
|
import contextlib
|
||||||
|
import logging
|
||||||
|
import wave
|
||||||
|
import itertools
|
||||||
|
import struct
|
||||||
|
from typing import cast, Any, AsyncGenerator, Coroutine, Dict, Optional, Tuple, List
|
||||||
|
import itertools
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
import lc3 # type: ignore # pylint: disable=E0401
|
||||||
|
except ImportError as e:
|
||||||
|
raise ImportError("Try `python -m pip install \".[lc3]\"`.") from e
|
||||||
|
|
||||||
|
from bumble.colors import color
|
||||||
|
from bumble import company_ids
|
||||||
|
from bumble import core
|
||||||
|
from bumble import gatt
|
||||||
|
from bumble import hci
|
||||||
|
from bumble.profiles import bap
|
||||||
|
from bumble.profiles import le_audio
|
||||||
|
from bumble.profiles import pbp
|
||||||
|
from bumble.profiles import bass
|
||||||
|
import bumble.device
|
||||||
|
import bumble.transport
|
||||||
|
import bumble.utils
|
||||||
|
from bumble.device import Host, BIGInfoAdvertisement, AdvertisingChannelMap
|
||||||
|
from bumble.audio import io as audio_io
|
||||||
|
|
||||||
|
from auracast import auracast_config
|
||||||
|
|
||||||
|
|
||||||
|
# modified from bumble
|
||||||
|
class ModWaveAudioInput(audio_io.ThreadedAudioInput):
|
||||||
|
"""Audio input that reads PCM samples from a .wav file."""
|
||||||
|
|
||||||
|
def __init__(self, filename: str) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self._filename = filename
|
||||||
|
self._wav: wave.Wave_read | None = None
|
||||||
|
self._bytes_read = 0
|
||||||
|
self.rewind=True
|
||||||
|
|
||||||
|
def _open(self) -> audio_io.PcmFormat:
|
||||||
|
self._wav = wave.open(self._filename, 'rb')
|
||||||
|
if self._wav.getsampwidth() != 2:
|
||||||
|
raise ValueError('sample width not supported')
|
||||||
|
return audio_io.PcmFormat(
|
||||||
|
audio_io.PcmFormat.Endianness.LITTLE,
|
||||||
|
audio_io.PcmFormat.SampleType.INT16,
|
||||||
|
self._wav.getframerate(),
|
||||||
|
self._wav.getnchannels(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _read(self, frame_size: int) -> bytes:
|
||||||
|
if not self._wav:
|
||||||
|
return b''
|
||||||
|
|
||||||
|
pcm_samples = self._wav.readframes(frame_size)
|
||||||
|
if not pcm_samples and self._bytes_read:
|
||||||
|
if not self.rewind:
|
||||||
|
return None
|
||||||
|
# Loop around.
|
||||||
|
self._wav.rewind()
|
||||||
|
self._bytes_read = 0
|
||||||
|
pcm_samples = self._wav.readframes(frame_size)
|
||||||
|
|
||||||
|
self._bytes_read += len(pcm_samples)
|
||||||
|
return pcm_samples
|
||||||
|
|
||||||
|
def _close(self) -> None:
|
||||||
|
if self._wav:
|
||||||
|
self._wav.close()
|
||||||
|
|
||||||
|
audio_io.WaveAudioInput = ModWaveAudioInput
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Logging
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
@contextlib.asynccontextmanager
|
||||||
|
async def create_device(config: auracast_config.AuracastGlobalConfig) -> AsyncGenerator[bumble.device.Device, Any]:
|
||||||
|
async with await bumble.transport.open_transport(config.transport) as (
|
||||||
|
hci_source,
|
||||||
|
hci_sink,
|
||||||
|
):
|
||||||
|
device_config = bumble.device.DeviceConfiguration(
|
||||||
|
name=config.device_name,
|
||||||
|
address=config.auracast_device_address,
|
||||||
|
keystore='JsonKeyStore',
|
||||||
|
#le_simultaneous_enabled=True #TODO: What is this doing ?
|
||||||
|
)
|
||||||
|
|
||||||
|
device = bumble.device.Device.from_config_with_hci(
|
||||||
|
device_config,
|
||||||
|
hci_source,
|
||||||
|
hci_sink,
|
||||||
|
)
|
||||||
|
await device.power_on()
|
||||||
|
|
||||||
|
yield device
|
||||||
|
|
||||||
|
|
||||||
|
def run_async(async_command: Coroutine) -> None:
|
||||||
|
try:
|
||||||
|
asyncio.run(async_command)
|
||||||
|
except core.ProtocolError as error:
|
||||||
|
if error.error_namespace == 'att' and error.error_code in list(
|
||||||
|
bass.ApplicationError
|
||||||
|
):
|
||||||
|
message = bass.ApplicationError(error.error_code).name
|
||||||
|
else:
|
||||||
|
message = str(error)
|
||||||
|
|
||||||
|
print(
|
||||||
|
color('!!! An error occurred while executing the command:', 'red'), message
|
||||||
|
)
|
||||||
|
|
||||||
|
async def init_broadcast(
|
||||||
|
device,
|
||||||
|
global_config : auracast_config.AuracastGlobalConfig,
|
||||||
|
big_config: List[auracast_config.AuracastBigConfig]
|
||||||
|
) -> dict:
|
||||||
|
|
||||||
|
bap_sampling_freq = getattr(bap.SamplingFrequency, f"FREQ_{global_config.auracast_sampling_rate_hz}")
|
||||||
|
bigs = {}
|
||||||
|
for i, conf in enumerate(big_config):
|
||||||
|
bigs[f'big{i}'] = {}
|
||||||
|
# Config advertising set
|
||||||
|
bigs[f'big{i}']['basic_audio_announcement'] = bap.BasicAudioAnnouncement(
|
||||||
|
presentation_delay=global_config.presentation_delay_us,
|
||||||
|
subgroups=[
|
||||||
|
bap.BasicAudioAnnouncement.Subgroup(
|
||||||
|
codec_id=hci.CodingFormat(codec_id=hci.CodecID.LC3),
|
||||||
|
codec_specific_configuration=bap.CodecSpecificConfiguration(
|
||||||
|
sampling_frequency=bap_sampling_freq,
|
||||||
|
frame_duration=bap.FrameDuration.DURATION_10000_US,
|
||||||
|
octets_per_codec_frame=global_config.octets_per_frame,
|
||||||
|
),
|
||||||
|
metadata=le_audio.Metadata(
|
||||||
|
[
|
||||||
|
le_audio.Metadata.Entry(
|
||||||
|
tag=le_audio.Metadata.Tag.LANGUAGE, data=conf.language.encode()
|
||||||
|
),
|
||||||
|
le_audio.Metadata.Entry(
|
||||||
|
tag=le_audio.Metadata.Tag.PROGRAM_INFO, data=conf.program_info.encode()
|
||||||
|
),
|
||||||
|
le_audio.Metadata.Entry(
|
||||||
|
tag=le_audio.Metadata.Tag.BROADCAST_NAME, data=conf.name.encode()
|
||||||
|
),
|
||||||
|
]
|
||||||
|
),
|
||||||
|
bis=[
|
||||||
|
bap.BasicAudioAnnouncement.BIS(
|
||||||
|
index=1,
|
||||||
|
codec_specific_configuration=bap.CodecSpecificConfiguration(
|
||||||
|
audio_channel_allocation=bap.AudioLocation.FRONT_LEFT
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
logger.info('Setup Advertising')
|
||||||
|
advertising_manufacturer_data = (
|
||||||
|
b''
|
||||||
|
if global_config.manufacturer_data is None
|
||||||
|
else bytes(
|
||||||
|
core.AdvertisingData(
|
||||||
|
[
|
||||||
|
(
|
||||||
|
core.AdvertisingData.MANUFACTURER_SPECIFIC_DATA,
|
||||||
|
struct.pack('<H', global_config.manufacturer_data[0])
|
||||||
|
+ global_config.manufacturer_data[1],
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
bigs[f'big{i}']['broadcast_audio_announcement'] = bap.BroadcastAudioAnnouncement(conf.id)
|
||||||
|
advertising_set = await device.create_advertising_set(
|
||||||
|
random_address=conf.random_address,
|
||||||
|
advertising_parameters=bumble.device.AdvertisingParameters(
|
||||||
|
advertising_event_properties=bumble.device.AdvertisingEventProperties(
|
||||||
|
is_connectable=False
|
||||||
|
),
|
||||||
|
primary_advertising_interval_min=100,
|
||||||
|
primary_advertising_interval_max=200,
|
||||||
|
advertising_sid=i,
|
||||||
|
primary_advertising_phy=hci.Phy.LE_1M, # 2m phy config throws error - because for primary advertising channels, 1mbit is only supported
|
||||||
|
secondary_advertising_phy=hci.Phy.LE_2M, # this is the secondary advertising beeing send on non advertising channels (extendend advertising)
|
||||||
|
#advertising_tx_power= # tx power in dbm (max 20)
|
||||||
|
#secondary_advertising_max_skip=10,
|
||||||
|
),
|
||||||
|
advertising_data=(
|
||||||
|
bigs[f'big{i}']['broadcast_audio_announcement'].get_advertising_data()
|
||||||
|
+ bytes(
|
||||||
|
core.AdvertisingData(
|
||||||
|
[(core.AdvertisingData.BROADCAST_NAME, conf.name.encode())]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
+ advertising_manufacturer_data
|
||||||
|
),
|
||||||
|
periodic_advertising_parameters=bumble.device.PeriodicAdvertisingParameters(
|
||||||
|
periodic_advertising_interval_min=80,
|
||||||
|
periodic_advertising_interval_max=160,
|
||||||
|
),
|
||||||
|
periodic_advertising_data=bigs[f'big{i}']['basic_audio_announcement'].get_advertising_data(),
|
||||||
|
auto_restart=True,
|
||||||
|
auto_start=True,
|
||||||
|
)
|
||||||
|
bigs[f'big{i}']['advertising_set'] = advertising_set
|
||||||
|
|
||||||
|
logging.info('Start Periodic Advertising')
|
||||||
|
await advertising_set.start_periodic()
|
||||||
|
|
||||||
|
logging.info('Setup BIG')
|
||||||
|
if global_config.qos_config.iso_int_multiple_10ms == 1:
|
||||||
|
frame_enable = 0
|
||||||
|
else:
|
||||||
|
frame_enable = 1
|
||||||
|
|
||||||
|
big = await device.create_big(
|
||||||
|
bigs[f'big{i}']['advertising_set'],
|
||||||
|
parameters=bumble.device.BigParameters(
|
||||||
|
num_bis=1,
|
||||||
|
sdu_interval=global_config.qos_config.iso_int_multiple_10ms*10000, # Is the same as iso interval
|
||||||
|
max_sdu=global_config.octets_per_frame,
|
||||||
|
max_transport_latency=global_config.qos_config.max_transport_latency_ms,
|
||||||
|
rtn=global_config.qos_config.number_of_retransmissions,
|
||||||
|
broadcast_code=(
|
||||||
|
bytes.fromhex(conf.code) if conf.code else None
|
||||||
|
),
|
||||||
|
framing=frame_enable # needed if iso interval is not frame interval of codedc
|
||||||
|
),
|
||||||
|
)
|
||||||
|
bigs[f'big{i}']['big'] = big
|
||||||
|
|
||||||
|
for bis_link in big.bis_links:
|
||||||
|
await bis_link.setup_data_path(
|
||||||
|
direction=bis_link.Direction.HOST_TO_CONTROLLER
|
||||||
|
)
|
||||||
|
|
||||||
|
iso_queue = bumble.device.IsoPacketStream(big.bis_links[0], conf.iso_que_len)
|
||||||
|
|
||||||
|
logging.info('Setup ISO Data Path')
|
||||||
|
|
||||||
|
bigs[f'big{i}']['iso_queue'] = iso_queue
|
||||||
|
|
||||||
|
logging.debug(f'big{i} parameters are:')
|
||||||
|
logging.debug('%s', pprint.pformat(vars(big)))
|
||||||
|
logging.debug(f'Finished setup of big{i}.')
|
||||||
|
|
||||||
|
await asyncio.sleep(i+1) # Wait for advertising to set up
|
||||||
|
|
||||||
|
def on_flow():
|
||||||
|
data_packet_queue = iso_queue.data_packet_queue
|
||||||
|
print(
|
||||||
|
f'\rPACKETS: pending={data_packet_queue.pending}, '
|
||||||
|
f'queued={data_packet_queue.queued}, '
|
||||||
|
f'completed={data_packet_queue.completed}',
|
||||||
|
end='',
|
||||||
|
)
|
||||||
|
|
||||||
|
if global_config.debug:
|
||||||
|
bigs[f'big{0}']['iso_queue'].data_packet_queue.on('flow', on_flow)
|
||||||
|
|
||||||
|
return bigs
|
||||||
|
|
||||||
|
async def init_audio(
|
||||||
|
bigs,
|
||||||
|
global_config : auracast_config.AuracastGlobalConfig,
|
||||||
|
big_config: List[auracast_config.AuracastBigConfig]
|
||||||
|
):
|
||||||
|
for i, big in enumerate(bigs.values()):
|
||||||
|
audio_source = big_config[i].audio_source
|
||||||
|
input_format = big_config[i].input_format
|
||||||
|
audio_input = await audio_io.create_audio_input(audio_source, input_format)
|
||||||
|
audio_input.rewind = big_config[i].loop_wav
|
||||||
|
pcm_format = await audio_input.open()
|
||||||
|
|
||||||
|
#try:
|
||||||
|
if pcm_format.channels != 1:
|
||||||
|
print("Only 1 channels PCM configurations are supported")
|
||||||
|
return
|
||||||
|
if pcm_format.sample_type == audio_io.PcmFormat.SampleType.INT16:
|
||||||
|
pcm_bit_depth = 16
|
||||||
|
elif pcm_format.sample_type == audio_io.PcmFormat.SampleType.FLOAT32:
|
||||||
|
pcm_bit_depth = None
|
||||||
|
else:
|
||||||
|
print("Only INT16 and FLOAT32 sample types are supported")
|
||||||
|
return
|
||||||
|
encoder = lc3.Encoder(
|
||||||
|
frame_duration_us=global_config.frame_duration_us,
|
||||||
|
sample_rate_hz=global_config.auracast_sampling_rate_hz,
|
||||||
|
num_channels=1,
|
||||||
|
input_sample_rate_hz=pcm_format.sample_rate,
|
||||||
|
)
|
||||||
|
lc3_frame_samples = encoder.get_frame_samples() # number of the pcm samples per lc3 frame
|
||||||
|
lc3_frame_size = global_config.octets_per_frame #encoder.get_frame_bytes(bitrate)
|
||||||
|
lc3_bytes_per_frame = lc3_frame_size #* 2 #multiplied by number of channels
|
||||||
|
|
||||||
|
big['pcm_bit_depth'] = pcm_bit_depth
|
||||||
|
big['lc3_bytes_per_frame'] = lc3_bytes_per_frame
|
||||||
|
big['lc3_frame_samples'] = lc3_frame_samples
|
||||||
|
big['audio_input'] = audio_input
|
||||||
|
big['encoder'] = encoder
|
||||||
|
|
||||||
|
class Streamer():
|
||||||
|
def __init__(self, bigs):
|
||||||
|
self.task = None
|
||||||
|
self.is_streaming = False
|
||||||
|
self.bigs = bigs
|
||||||
|
|
||||||
|
def start_streaming(self):
|
||||||
|
if not self.is_streaming:
|
||||||
|
self.task = asyncio.create_task(self.stream())
|
||||||
|
else:
|
||||||
|
logging.warning('Streamer is already running')
|
||||||
|
|
||||||
|
def stop_streaming(self):
|
||||||
|
"""Stops the background task if running."""
|
||||||
|
if self.is_streaming:
|
||||||
|
self.is_streaming = False
|
||||||
|
if self.task:
|
||||||
|
self.task.cancel() # Cancel the task safely
|
||||||
|
self.task = None
|
||||||
|
|
||||||
|
async def stream(self):
|
||||||
|
# TODO: do some pre buffering so the stream is stable from the beginning. One half iso queue would be appropriate
|
||||||
|
logging.info("Streaming audio...")
|
||||||
|
bigs = self.bigs
|
||||||
|
self.is_streaming = True
|
||||||
|
while self.is_streaming:
|
||||||
|
stream_finished = [False for _ in range(len(bigs))]
|
||||||
|
for i, big in enumerate(bigs.values()):
|
||||||
|
pcm_frame = await anext(big['audio_input'].frames(big['lc3_frame_samples']), None)
|
||||||
|
if pcm_frame is None: # Not all streams may stop at the same time
|
||||||
|
stream_finished[i] = True
|
||||||
|
continue
|
||||||
|
|
||||||
|
lc3_frame = big['encoder'].encode(
|
||||||
|
pcm_frame, num_bytes=big['lc3_bytes_per_frame'], bit_depth=big['pcm_bit_depth']
|
||||||
|
)
|
||||||
|
await big['iso_queue'].write(lc3_frame)
|
||||||
|
|
||||||
|
if all(stream_finished): # Take into account that multiple files have different lengths
|
||||||
|
logging.info('All streams finished, stopping streamer')
|
||||||
|
self.is_streaming = False
|
||||||
|
break
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Main
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def broadcast(global_conf: auracast_config.AuracastGlobalConfig, big_conf: List[auracast_config.AuracastBigConfig]):
|
||||||
|
"""Start a broadcast."""
|
||||||
|
async with create_device(global_conf) as device:
|
||||||
|
if not device.supports_le_periodic_advertising:
|
||||||
|
logger.error(color('Periodic advertising not supported', 'red'))
|
||||||
|
return
|
||||||
|
|
||||||
|
bigs = await init_broadcast( # the bigs dictionary contains all the global configurations
|
||||||
|
device,
|
||||||
|
global_conf,
|
||||||
|
big_conf
|
||||||
|
)
|
||||||
|
await init_audio(
|
||||||
|
bigs,
|
||||||
|
global_conf,
|
||||||
|
big_conf
|
||||||
|
)
|
||||||
|
streamer = Streamer(bigs)
|
||||||
|
streamer.start_streaming()
|
||||||
|
|
||||||
|
await asyncio.wait([streamer.task])
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
if __name__ == "__main__":
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.DEBUG,
|
||||||
|
format='%(module)s.py:%(lineno)d %(levelname)s: %(message)s'
|
||||||
|
)
|
||||||
|
|
||||||
|
global_conf = auracast_config.global_base_config
|
||||||
|
|
||||||
|
#global_conf.transport='serial:/dev/serial/by-id/usb-ZEPHYR_Zephyr_HCI_UART_sample_81BD14B8D71B5662-if00,1000000,rtscts' # transport for nrf52 dongle
|
||||||
|
|
||||||
|
#global_conf.transport='serial:/dev/serial/by-id/usb-SEGGER_J-Link_001050076061-if02,1000000,rtscts' # transport for nrf53dk
|
||||||
|
|
||||||
|
#global_conf.transport='serial:/dev/serial/by-id/usb-SEGGER_J-Link_001057705357-if02,1000000,rtscts' # transport for nrf54l15dk
|
||||||
|
|
||||||
|
global_conf.transport='serial:/dev/serial/by-id/usb-ZEPHYR_Zephyr_HCI_UART_sample_81BD14B8D71B5662-if00,115200,rtscts' #nrf52dongle hci_uart usb cdc
|
||||||
|
|
||||||
|
# global_conf.transport='usb:2fe3:000b' #nrf52dongle hci_usb # TODO: iso packet over usb not supported
|
||||||
|
|
||||||
|
|
||||||
|
# TODO: How can we use other iso interval than 10ms ?(medium or low rel) ? - nrf53audio receiver repports I2S tx underrun
|
||||||
|
#global_conf.qos_config = auracast_config.qos_config_mono_medium_rel
|
||||||
|
global_conf.qos_config = auracast_config.qos_config_mono_high_rel
|
||||||
|
|
||||||
|
bigs = [
|
||||||
|
auracast_config.broadcast_de,
|
||||||
|
auracast_config.broadcast_en,
|
||||||
|
auracast_config.broadcast_fr,
|
||||||
|
#auracast_config.broadcast_es,
|
||||||
|
#auracast_config.broadcast_it,
|
||||||
|
]
|
||||||
|
#for big in bigs: # TODO. investigate this further
|
||||||
|
# big.code = 'ff'*16 # returns hci/HCI_ENCRYPTION_MODE_NOT_ACCEPTABLE_ERROR
|
||||||
|
#big.code = '78 e5 dc f1 34 ab 42 bf c1 92 ef dd 3a fd 67 ae'
|
||||||
|
|
||||||
|
|
||||||
|
# 16kHz works reliably with 3 streams
|
||||||
|
# 24kHz is only working with 2 streams - probably airtime constraint
|
||||||
|
# TODO: with more than three broadcasters (16kHz) no advertising (no primary channels is present anymore)
|
||||||
|
# TODO: find the bottleneck - probably airtime
|
||||||
|
# TODO: test encrypted streams
|
||||||
|
|
||||||
|
global_conf.auracast_sampling_rate_hz = 16000
|
||||||
|
global_conf.octets_per_frame = 40 # 32kbps@16kHz
|
||||||
|
#global_conf.debug = True
|
||||||
|
|
||||||
|
run_async(
|
||||||
|
broadcast(
|
||||||
|
global_conf,
|
||||||
|
bigs
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# TODO: possible inputs:
|
||||||
|
# wav file locally
|
||||||
|
# precoded lc3 file locally
|
||||||
|
# realtime audio locally
|
||||||
|
# realtime audio network lc3 coded
|
||||||
|
# (realtime audio network uncoded)
|
||||||
148
auracast/multicast_control.py
Normal file
148
auracast/multicast_control.py
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
import logging
|
||||||
|
from typing import cast, Any, AsyncGenerator, Coroutine, Dict, Optional, Tuple, List
|
||||||
|
import bumble
|
||||||
|
import bumble.device
|
||||||
|
import bumble.transport
|
||||||
|
import bumble.utils
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import aioconsole
|
||||||
|
from auracast import multicast
|
||||||
|
from auracast import auracast_config
|
||||||
|
|
||||||
|
class Multicaster:
|
||||||
|
"""
|
||||||
|
A class responsible for managing the multicasting and audio streaming process.
|
||||||
|
|
||||||
|
It provides methods to initialize and shutdown the broadcasting, as well as start and stop the streaming.
|
||||||
|
The class also manages the underlying device and advertising sets.
|
||||||
|
"""
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
global_conf: auracast_config.AuracastGlobalConfig,
|
||||||
|
big_conf: List[auracast_config.AuracastBigConfig]
|
||||||
|
):
|
||||||
|
self.is_auracast_init = False
|
||||||
|
self.is_audio_init = False
|
||||||
|
self.streaming = False
|
||||||
|
self.global_conf = global_conf
|
||||||
|
self.big_conf = big_conf
|
||||||
|
self.device = None
|
||||||
|
self.bigs = None
|
||||||
|
self.streamer=None
|
||||||
|
|
||||||
|
async def init_broadcast(self):
|
||||||
|
self.device_acm = multicast.create_device(self.global_conf)
|
||||||
|
|
||||||
|
agen = self.device_acm.__aenter__() # Manually triggering setup
|
||||||
|
device = await agen
|
||||||
|
|
||||||
|
self.bigs = await multicast.init_broadcast( # the bigs dictionary contains all the global configurations
|
||||||
|
device,
|
||||||
|
self.global_conf,
|
||||||
|
self.big_conf
|
||||||
|
)
|
||||||
|
self.device = device
|
||||||
|
self.is_auracast_init = True
|
||||||
|
|
||||||
|
async def init_audio(self):
|
||||||
|
await multicast.init_audio(
|
||||||
|
self.bigs,
|
||||||
|
self.global_conf,
|
||||||
|
self.big_conf
|
||||||
|
)
|
||||||
|
self.is_audio_init = True
|
||||||
|
self.streamer = multicast.Streamer(self.bigs)
|
||||||
|
|
||||||
|
def start_streaming(self):
|
||||||
|
self.streamer.start_streaming()
|
||||||
|
|
||||||
|
def stop_streaming(self):
|
||||||
|
if self.streamer is not None:
|
||||||
|
self.streamer.stop_streaming()
|
||||||
|
|
||||||
|
async def reset(self):
|
||||||
|
await self.shutdown() # Manually triggering teardown
|
||||||
|
self.__init__(self.global_conf, self.big_conf)
|
||||||
|
|
||||||
|
async def shutdown(self):
|
||||||
|
self.is_auracast_init = False
|
||||||
|
self. is_audio_init = False
|
||||||
|
if self.device:
|
||||||
|
await self.device.stop_advertising()
|
||||||
|
if self.bigs:
|
||||||
|
for big in self.bigs.values():
|
||||||
|
if big['advertising_set']:
|
||||||
|
await big['advertising_set'].stop()
|
||||||
|
|
||||||
|
await self.device_acm.__aexit__(None, None, None) # Manually triggering teardown
|
||||||
|
|
||||||
|
|
||||||
|
# example commandline ui
|
||||||
|
async def command_line_ui(caster: Multicaster):
|
||||||
|
while True:
|
||||||
|
command = await aioconsole.ainput("\nCommands: [start_audio|stop_audio|stop|init|init_audio|quit] > ")
|
||||||
|
|
||||||
|
if command.strip().lower() == "start_audio":
|
||||||
|
caster.start_streaming()
|
||||||
|
print("Audio started!")
|
||||||
|
|
||||||
|
elif command.strip().lower() == "stop_audio":
|
||||||
|
caster.stop_streaming()
|
||||||
|
print("Audio stopped!")
|
||||||
|
|
||||||
|
elif command.strip().lower() == "stop":
|
||||||
|
print("👋 Stopping...")
|
||||||
|
caster.stop_streaming()
|
||||||
|
await caster.reset()
|
||||||
|
|
||||||
|
elif command.strip().lower() == "init":
|
||||||
|
await caster.reset()
|
||||||
|
await caster.init_broadcast()
|
||||||
|
await caster.init_audio()
|
||||||
|
|
||||||
|
elif command.strip().lower() == "init_audio":
|
||||||
|
await caster.init_audio()
|
||||||
|
|
||||||
|
elif command.strip().lower() == "quit":
|
||||||
|
print("👋 Exiting...")
|
||||||
|
if caster.device:
|
||||||
|
caster.stop_streaming()
|
||||||
|
await caster.shutdown()
|
||||||
|
break # Exit loop
|
||||||
|
else:
|
||||||
|
print("Invalid command.")
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.DEBUG,
|
||||||
|
format='%(module)s.py:%(lineno)d %(levelname)s: %(message)s'
|
||||||
|
)
|
||||||
|
|
||||||
|
global_conf = auracast_config.global_base_config
|
||||||
|
#global_conf.transport='serial:/dev/serial/by-id/usb-SEGGER_J-Link_001057705357-if02,1000000,rtscts' # transport for nrf54l15dk
|
||||||
|
global_conf.transport='serial:/dev/serial/by-id/usb-ZEPHYR_Zephyr_HCI_UART_sample_81BD14B8D71B5662-if00,115200,rtscts' #nrf52dongle hci_uart usb cdc
|
||||||
|
|
||||||
|
big_conf = [
|
||||||
|
auracast_config.broadcast_de,
|
||||||
|
auracast_config.broadcast_en,
|
||||||
|
auracast_config.broadcast_fr,
|
||||||
|
#auracast_config.broadcast_es,
|
||||||
|
#auracast_config.broadcast_it,
|
||||||
|
]
|
||||||
|
for conf in big_conf:
|
||||||
|
conf.loop_wav = False
|
||||||
|
|
||||||
|
# look into:
|
||||||
|
#async with MyAPI() as api:
|
||||||
|
#pass
|
||||||
|
|
||||||
|
caster = Multicaster(global_conf, big_conf)
|
||||||
|
await caster.init_broadcast()
|
||||||
|
await caster.init_audio()
|
||||||
|
|
||||||
|
await command_line_ui(caster)
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
# Run the application
|
||||||
|
asyncio.run(main())
|
||||||
1
auracast/run_btmon_rtt.sh
Normal file
1
auracast/run_btmon_rtt.sh
Normal file
@@ -0,0 +1 @@
|
|||||||
|
btmon --jlink NRF54L15_M33,1057705357
|
||||||
@@ -4,8 +4,10 @@ version = "0.0.1"
|
|||||||
requires-python = ">=3.8"
|
requires-python = ">=3.8"
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bumble @ git+ssh://git@hinterwaldner.duckdns.org:222/auracaster/bumble.git@af466c29704d4d47b291e697b1990621c0b7d86b",
|
"bumble @ git+ssh://git@ssh.pstruebi.xyz:222/auracaster/bumble_mirror.git@12bcdb7770c0d57a094bc0a96cd52e701f97fece",
|
||||||
"lc3 @ git+https://github.com/google/liblc3.git@7558637303106c7ea971e7bb8cedf379d3e08bcc",
|
"lc3 @ git+ssh://git@ssh.pstruebi.xyz:222/auracaster/liblc3.git@7558637303106c7ea971e7bb8cedf379d3e08bcc",
|
||||||
|
"sounddevice",
|
||||||
|
"aioconsole"
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
|
|||||||
Reference in New Issue
Block a user