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:
2025-03-04 10:21:53 +01:00
parent 77c47c39cb
commit dc8b6cc66e
27 changed files with 495 additions and 86 deletions

0
src/auracast/__init__.py Normal file
View File

View 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
View 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)

View 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())

View 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())

View 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)

View File

@@ -0,0 +1 @@
btmon --jlink NRF54L15_M33,1057705357

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

25
src/auracast/testdata/encode_lc3.py vendored Normal file
View 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)

View File

View 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