feature/precode (#2)
- Implent support to play precoded files - Implement a basic client -server architecture Reviewed-on: https://gitea.pstruebi.xyz/auracaster/bumble-auracast/pulls/2
This commit was merged in pull request #2.
This commit is contained in:
0
src/auracast/__init__.py
Normal file
0
src/auracast/__init__.py
Normal file
106
src/auracast/auracast_config.py
Normal file
106
src/auracast/auracast_config.py
Normal file
@@ -0,0 +1,106 @@
|
||||
from bumble import hci
|
||||
from bumble.profiles import bap
|
||||
from dataclasses import dataclass
|
||||
|
||||
# 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
|
||||
class AuracastGlobalConfig:
|
||||
qos_config: AuracastQoSConfig
|
||||
debug: bool = False
|
||||
device_name: str = 'Auracaster'
|
||||
transport: str = ''
|
||||
auracast_device_address: hci.Address = hci.Address('F0:F1:F2:F3:F4:F5')
|
||||
auracast_sampling_rate_hz: int = 16000
|
||||
octets_per_frame: int = 40 #48kbps@24kHz # bitrate = octets_per_frame * 8 / frame len
|
||||
frame_duration_us: int = 10000
|
||||
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
|
||||
class AuracastBigConfig:
|
||||
id: int = 123456,
|
||||
random_address: hci.Address = hci.Address('F1:F1:F2:F3:F4:F5')
|
||||
code: str = None # Broadcast_Code – a 16-octet parameter provided by the Host
|
||||
language: str = 'eng' # See: https://en.wikipedia.org/wiki/List_of_ISO_639_language_codes
|
||||
name: str = 'Broadcast0'
|
||||
program_info: str = 'Some Announcements'
|
||||
audio_source: str = 'file:./auracast/announcement_48_10_96000_en.wav'
|
||||
input_format: str = 'auto'
|
||||
loop: bool = True
|
||||
precode_wav: bool = False
|
||||
iso_que_len: int = 64
|
||||
|
||||
|
||||
# Instanciate some example configurations
|
||||
broadcast_de = AuracastBigConfig(
|
||||
id=12,
|
||||
random_address=hci.Address('F1:F1:F2:F3:F4:F5'),
|
||||
name = 'Broadcast0',
|
||||
language='deu',
|
||||
program_info = 'Announcements German',
|
||||
audio_source = 'file:./testdata/announcement_de.wav',
|
||||
)
|
||||
|
||||
broadcast_en = AuracastBigConfig(
|
||||
id=123,
|
||||
random_address=hci.Address('F2:F1:F2:F3:F4:F5'),
|
||||
name = 'Broadcast1',
|
||||
language='eng',
|
||||
program_info = 'Announcements English',
|
||||
audio_source = 'file:./testdata/announcement_en.wav',
|
||||
)
|
||||
|
||||
broadcast_fr = AuracastBigConfig(
|
||||
id=1234,
|
||||
random_address=hci.Address('F3:F1:F2:F3:F4:F5'),
|
||||
name = 'Broadcast2',
|
||||
language='fra',
|
||||
program_info = 'Announcements French',
|
||||
audio_source = 'file:./testdata/announcement_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:./testdata/announcement_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:./testdata/announcement_it.wav',
|
||||
)
|
||||
558
src/auracast/multicast.py
Normal file
558
src/auracast/multicast.py
Normal file
@@ -0,0 +1,558 @@
|
||||
# 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
|
||||
from auracast.utils.read_lc3_file import read_lc3_file
|
||||
|
||||
|
||||
# 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
|
||||
|
||||
|
||||
class Streamer():
|
||||
"""
|
||||
Streamer class that supports multiple input formats. See bumble for streaming from wav or device
|
||||
Added functionallity on top of bumble:
|
||||
- loop parameter
|
||||
- if True the audio_source will be looped for ever
|
||||
- precode wav files
|
||||
- lc3 coded files
|
||||
- just use a .lc3 file as audio_source
|
||||
- lc3 coded from ram
|
||||
- use a bytestring b'' as audio_source
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
bigs,
|
||||
global_config : auracast_config.AuracastGlobalConfig,
|
||||
big_config: List[auracast_config.AuracastBigConfig]
|
||||
):
|
||||
self.task = None
|
||||
self.is_streaming = False
|
||||
self.bigs = bigs
|
||||
self.global_config = global_config
|
||||
self.big_config = big_config
|
||||
|
||||
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):
|
||||
bigs = self.bigs
|
||||
big_config = self.big_config
|
||||
global_config = self.global_config
|
||||
# init
|
||||
for i, big in enumerate(bigs.values()):
|
||||
audio_source = big_config[i].audio_source
|
||||
input_format = big_config[i].input_format
|
||||
|
||||
# precoded lc3 from ram
|
||||
if isinstance(big_config[i].audio_source, bytes):
|
||||
big['precoded'] = True
|
||||
|
||||
lc3_frames = iter(big_config[i].audio_source)
|
||||
|
||||
if big_config[i].loop:
|
||||
lc3_frames = itertools.cycle(lc3_frames)
|
||||
big['lc3_frames'] = lc3_frames
|
||||
|
||||
# precoded lc3 file
|
||||
elif big_config[i].audio_source.endswith('.lc3'):
|
||||
big['precoded'] = True
|
||||
filename = big_config[i].audio_source.replace('file:', '')
|
||||
|
||||
lc3_bytes = read_lc3_file(filename)
|
||||
lc3_frames = iter(lc3_bytes)
|
||||
|
||||
if big_config[i].loop:
|
||||
lc3_frames = itertools.cycle(lc3_frames)
|
||||
big['lc3_frames'] = lc3_frames
|
||||
|
||||
# use wav files and code them entirely before streaming
|
||||
elif big_config[i].precode_wav and big_config[i].audio_source.endswith('.wav'):
|
||||
big['precoded'] = True
|
||||
|
||||
audio_input = await audio_io.create_audio_input(audio_source, input_format)
|
||||
audio_input.rewind = False
|
||||
pcm_format = await audio_input.open()
|
||||
|
||||
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_bytes = b''
|
||||
async for pcm_frame in audio_input.frames(lc3_frame_samples):
|
||||
lc3_bytes += encoder.encode(
|
||||
pcm_frame, num_bytes=global_config.octets_per_frame, bit_depth=pcm_bit_depth
|
||||
)
|
||||
lc3_frames = iter(lc3_bytes)
|
||||
|
||||
# have a look at itertools.islice
|
||||
if big_config[i].loop:
|
||||
lc3_frames = itertools.cycle(lc3_frames)
|
||||
big['lc3_frames'] = lc3_frames
|
||||
|
||||
# anything else, e.g. realtime stream from device (bumble)
|
||||
else:
|
||||
audio_input = await audio_io.create_audio_input(audio_source, input_format)
|
||||
audio_input.rewind = big_config[i].loop
|
||||
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
|
||||
|
||||
big['pcm_bit_depth'] = pcm_bit_depth
|
||||
big['lc3_frame_samples'] = lc3_frame_samples
|
||||
big['audio_input'] = audio_input
|
||||
big['encoder'] = encoder
|
||||
big['precoded'] = False
|
||||
|
||||
# Need for coded an uncoded audio
|
||||
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['lc3_bytes_per_frame'] = lc3_bytes_per_frame
|
||||
|
||||
# TODO: Maybe 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
|
||||
# One streamer fits all
|
||||
while self.is_streaming:
|
||||
stream_finished = [False for _ in range(len(bigs))]
|
||||
for i, big in enumerate(bigs.values()):
|
||||
|
||||
if big['precoded']:# everything was already lc3 coded beforehand
|
||||
lc3_frame = bytes(
|
||||
itertools.islice(big['lc3_frames'], big['lc3_bytes_per_frame'])
|
||||
)
|
||||
else:
|
||||
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
|
||||
)
|
||||
streamer = Streamer(bigs, global_conf, big_conf)
|
||||
streamer.start_streaming()
|
||||
|
||||
await asyncio.wait([streamer.task])
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
if __name__ == "__main__":
|
||||
import os
|
||||
|
||||
if os.environ['LOG_LEVEL']:
|
||||
log_level = getattr(logging, os.environ['LOG_LEVEL'])
|
||||
else:
|
||||
log_level = logging.DEBUG
|
||||
log_level = os.environ['LOG_LEVEL']
|
||||
logging.basicConfig(
|
||||
level=log_level,
|
||||
format='%(module)s.py:%(lineno)d %(levelname)s: %(message)s'
|
||||
)
|
||||
os.chdir(os.path.dirname(__file__))
|
||||
|
||||
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: encrypted streams are not working
|
||||
#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'
|
||||
big.precode_wav = True
|
||||
big.audio_source = big.audio_source.replace('.wav', '_10_16_32.lc3') #lc3 precoded files
|
||||
big.audio_source = read_lc3_file(big.audio_source) # load files in advance
|
||||
|
||||
# 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)
|
||||
146
src/auracast/multicast_control.py
Normal file
146
src/auracast/multicast_control.py
Normal file
@@ -0,0 +1,146 @@
|
||||
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
|
||||
|
||||
def get_status(self):
|
||||
streaming = self.streamer.is_streaming if self.streamer is not None else False
|
||||
return {
|
||||
'is_initialized': self.is_auracast_init,
|
||||
'is_streaming': streaming,
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
def start_streaming(self):
|
||||
self.streamer = multicast.Streamer(self.bigs, self.global_conf, self.big_conf)
|
||||
self.streamer.start_streaming()
|
||||
|
||||
def stop_streaming(self):
|
||||
if self.streamer is not None:
|
||||
self.streamer.stop_streaming()
|
||||
self.streamer = None
|
||||
|
||||
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 = False
|
||||
|
||||
# look into:
|
||||
#async with MyAPI() as api:
|
||||
#pass
|
||||
|
||||
caster = Multicaster(global_conf, big_conf)
|
||||
await caster.init_broadcast()
|
||||
|
||||
await command_line_ui(caster)
|
||||
|
||||
if __name__ == '__main__':
|
||||
# Run the application
|
||||
asyncio.run(main())
|
||||
36
src/auracast/multicast_control_client.py
Normal file
36
src/auracast/multicast_control_client.py
Normal file
@@ -0,0 +1,36 @@
|
||||
import requests
|
||||
from auracast.utils.read_lc3_file import read_lc3_file
|
||||
|
||||
BASE_URL = "http://127.0.0.1:5000" # Adjust based on your actual API URL
|
||||
|
||||
def initialize():
|
||||
response = requests.post(f"{BASE_URL}/init")
|
||||
return response.json()
|
||||
|
||||
def shutdown():
|
||||
response = requests.post(f"{BASE_URL}/shutdown")
|
||||
return response.json()
|
||||
|
||||
def stop_audio():
|
||||
response = requests.post(f"{BASE_URL}/stop_audio")
|
||||
return response.json()
|
||||
|
||||
def send_audio():
|
||||
test_audio_data = { # TODO: investigate further whats the best way to actually transfer the data
|
||||
"broadcast_de": read_lc3_file('src/auracast/testdata/announcement_de_10_16_32.lc3').decode('latin-1'),
|
||||
"broadcast_en": read_lc3_file('src/auracast/testdata/announcement_en_10_16_32.lc3').decode('latin-1')
|
||||
}
|
||||
|
||||
response = requests.post(f"{BASE_URL}/stream_lc3", json=test_audio_data)
|
||||
return response.json()
|
||||
|
||||
def get_status():
|
||||
response = requests.get(f"{BASE_URL}/status")
|
||||
return response.json()
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("Getting status:", get_status())
|
||||
print("Initializing server:", initialize())
|
||||
print("Getting status:", get_status())
|
||||
print("Sending audio:", send_audio())
|
||||
print("Getting status:", get_status())
|
||||
93
src/auracast/multicast_server.py
Normal file
93
src/auracast/multicast_server.py
Normal file
@@ -0,0 +1,93 @@
|
||||
from dataclasses import asdict
|
||||
from quart import Quart, request, jsonify # TODO: evalute if classic flask should be used instead
|
||||
from auracast import multicast_control
|
||||
from auracast import auracast_config
|
||||
|
||||
app = Quart(__name__)
|
||||
|
||||
# Initialize the multicaster instance globally
|
||||
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 = { # TODO: use another dataclass for this to be able to iterate over the names
|
||||
'broadcast_de': auracast_config.broadcast_de,
|
||||
'broadcast_en': auracast_config.broadcast_en,
|
||||
'broadcast_fr': auracast_config.broadcast_fr,
|
||||
#auracast_config.broadcast_es,
|
||||
#auracast_config.broadcast_it,
|
||||
}
|
||||
for conf in big_conf.values():
|
||||
conf.loop = False
|
||||
|
||||
multicaster = multicast_control.Multicaster(
|
||||
global_conf,
|
||||
list(big_conf.values()),
|
||||
)
|
||||
|
||||
@app.route('/init', methods=['POST'])
|
||||
async def initialize():
|
||||
"""Initializes the broadcasters."""
|
||||
#data = await request.json
|
||||
#global_conf = auracast_config.AuracastGlobalConfig.from_dict(data['global_config'])
|
||||
#stream_configs = [auracast_config.AuracastBigConfig.from_dict(big) for big in data['big_configs']]
|
||||
try:
|
||||
await multicaster.init_broadcast()
|
||||
return jsonify({"status": "initialized"}), 200
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
@app.route('/shutdown', methods=['POST'])
|
||||
async def stop():
|
||||
"""Stops broadcasting."""
|
||||
try:
|
||||
await multicaster.reset()
|
||||
return jsonify({"status": "stopped"}), 200
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
@app.route('/stop_audio', methods=['POST'])
|
||||
async def stop_audio():
|
||||
"""Stops streaming."""
|
||||
try:
|
||||
multicaster.stop_streaming()
|
||||
return jsonify({"status": "stopped"}), 200
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
@app.route('/stream_lc3', methods=['POST'])
|
||||
async def send_audio():
|
||||
"""Streams pre-coded LC3 audio.
|
||||
# post data in the format
|
||||
{
|
||||
broadcast_de: b''
|
||||
broadcast_fr: b''
|
||||
|
||||
}
|
||||
"""
|
||||
post_data = await request.json
|
||||
try:
|
||||
for key, val in big_conf.items():
|
||||
if key in post_data:
|
||||
val.audio_source = post_data[key].encode('latin-1')
|
||||
else:
|
||||
val.audio_source = b''
|
||||
|
||||
multicaster.big_conf = list(big_conf.values())
|
||||
multicaster.start_streaming()
|
||||
return jsonify({"status": "audio_sent"}), 200
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
# TODO: Also a queue should be implemented - probably as its own endpoint,
|
||||
|
||||
|
||||
@app.route('/status', methods=['GET'])
|
||||
async def get_status():
|
||||
"""Gets the current status of the multicaster."""
|
||||
status = multicaster.get_status()
|
||||
# TODO: also get queue status, announcements, samples etc.
|
||||
return jsonify({"status": status}), 200
|
||||
|
||||
if __name__ == '__main__':
|
||||
app.run(host='0.0.0.0', port=5000, debug=True)
|
||||
1
src/auracast/run_btmon_rtt.sh
Normal file
1
src/auracast/run_btmon_rtt.sh
Normal file
@@ -0,0 +1 @@
|
||||
btmon --jlink NRF54L15_M33,1057705357
|
||||
BIN
src/auracast/testdata/announcement_de.wav
vendored
Normal file
BIN
src/auracast/testdata/announcement_de.wav
vendored
Normal file
Binary file not shown.
BIN
src/auracast/testdata/announcement_de_10_16_32.lc3
vendored
Normal file
BIN
src/auracast/testdata/announcement_de_10_16_32.lc3
vendored
Normal file
Binary file not shown.
BIN
src/auracast/testdata/announcement_en.wav
vendored
Normal file
BIN
src/auracast/testdata/announcement_en.wav
vendored
Normal file
Binary file not shown.
BIN
src/auracast/testdata/announcement_en_10_16_32.lc3
vendored
Normal file
BIN
src/auracast/testdata/announcement_en_10_16_32.lc3
vendored
Normal file
Binary file not shown.
BIN
src/auracast/testdata/announcement_en_stereo.wav
vendored
Normal file
BIN
src/auracast/testdata/announcement_en_stereo.wav
vendored
Normal file
Binary file not shown.
BIN
src/auracast/testdata/announcement_en_stereo_10_16_32.lc3
vendored
Normal file
BIN
src/auracast/testdata/announcement_en_stereo_10_16_32.lc3
vendored
Normal file
Binary file not shown.
BIN
src/auracast/testdata/announcement_es.wav
vendored
Normal file
BIN
src/auracast/testdata/announcement_es.wav
vendored
Normal file
Binary file not shown.
BIN
src/auracast/testdata/announcement_es_10_16_32.lc3
vendored
Normal file
BIN
src/auracast/testdata/announcement_es_10_16_32.lc3
vendored
Normal file
Binary file not shown.
BIN
src/auracast/testdata/announcement_fr.wav
vendored
Normal file
BIN
src/auracast/testdata/announcement_fr.wav
vendored
Normal file
Binary file not shown.
BIN
src/auracast/testdata/announcement_fr_10_16_32.lc3
vendored
Normal file
BIN
src/auracast/testdata/announcement_fr_10_16_32.lc3
vendored
Normal file
Binary file not shown.
BIN
src/auracast/testdata/announcement_it.wav
vendored
Normal file
BIN
src/auracast/testdata/announcement_it.wav
vendored
Normal file
Binary file not shown.
BIN
src/auracast/testdata/announcement_it_10_16_32.lc3
vendored
Normal file
BIN
src/auracast/testdata/announcement_it_10_16_32.lc3
vendored
Normal file
Binary file not shown.
25
src/auracast/testdata/encode_lc3.py
vendored
Normal file
25
src/auracast/testdata/encode_lc3.py
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
# use liblc3
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
frame_dur_ms=10
|
||||
srate=16000
|
||||
bps=32000
|
||||
|
||||
if __name__ == '__main__':
|
||||
workdir = os.path.dirname(__file__)
|
||||
os.chdir(workdir)
|
||||
files = os.listdir(workdir)
|
||||
filtered = [file for file in files if file.endswith('.wav')]
|
||||
|
||||
for file in filtered:
|
||||
cmd = [
|
||||
'elc3',
|
||||
'-b', f'{bps}',
|
||||
'-m', f'{frame_dur_ms}' ,
|
||||
'-r', f'{srate}',
|
||||
f'{file}', f'{file.replace('.wav', '')}_{frame_dur_ms}_{srate//1000}_{bps//1000}.lc3'
|
||||
]
|
||||
print("Executing: ", " ".join(cmd))
|
||||
ret = subprocess.run(cmd, check=True)
|
||||
print(ret.returncode, ret.stdout, ret.stderr)
|
||||
0
src/auracast/utils/__init__.py
Normal file
0
src/auracast/utils/__init__.py
Normal file
32
src/auracast/utils/read_lc3_file.py
Normal file
32
src/auracast/utils/read_lc3_file.py
Normal file
@@ -0,0 +1,32 @@
|
||||
import logging
|
||||
import struct
|
||||
|
||||
|
||||
def read_lc3_file(filepath):
|
||||
filepath = filepath.replace('file:', '')
|
||||
with open(filepath, 'rb') as f_lc3:
|
||||
header = struct.unpack('=HHHHHHHI', f_lc3.read(18))
|
||||
if header[0] != 0xcc1c:
|
||||
raise ValueError('Invalid bitstream file')
|
||||
|
||||
# found in liblc3 - decoder.py
|
||||
samplerate = header[2] * 100
|
||||
nchannels = header[4]
|
||||
frame_duration = header[5] * 10
|
||||
stream_length = header[7]
|
||||
#lc3_frame_size = struct.unpack('=H', f_lc3.read(2))[0]
|
||||
logging.info('Loaded lc3 file: %s', filepath)
|
||||
logging.info('samplerate: %s', samplerate)
|
||||
logging.info('nchannels %s', nchannels)
|
||||
logging.info('frame_duration %s', frame_duration)
|
||||
logging.info('stream_length %s', stream_length)
|
||||
|
||||
lc3_bytes= b''
|
||||
while True:
|
||||
b = f_lc3.read(2)
|
||||
if b == b'':
|
||||
break
|
||||
lc3_frame_size = struct.unpack('=H', b)[0]
|
||||
lc3_bytes += f_lc3.read(lc3_frame_size)
|
||||
|
||||
return lc3_bytes
|
||||
Reference in New Issue
Block a user