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

7
.vscode/tasks.json vendored
View File

@@ -3,10 +3,15 @@
// for the documentation about the tasks.json format // for the documentation about the tasks.json format
"version": "2.0.0", "version": "2.0.0",
"tasks": [ "tasks": [
{
"label": "Setup project for development",
"type": "shell",
"command": "./venv/bin/python -m pip install -e ."
},
{ {
"label": "pip install -e bumble", "label": "pip install -e bumble",
"type": "shell", "type": "shell",
"command": "./venv/bin/python -m pip install -e ../bumble --config-settings editable_mode=compat" "command": "./venv/bin/python -m pip install -e ../bumble --config-settings editable_mode=compat"
} },
] ]
} }

26
Dockerfile Normal file
View File

@@ -0,0 +1,26 @@
# TODO: investigate using -alpine in the future
FROM python:3.11
WORKDIR /usr/src/app
COPY ./pyproject.toml .
COPY ./src/ .
RUN sed /^StrictHostKeyChecking/d /etc/ssh/ssh_config; \
echo StrictHostKeyChecking no >> /etc/ssh/ssh_config
RUN --mount=type=cache,target=/root/.cache \
--mount=type=ssh,required=true \
pip install ./
# make sure to install sudo apt install docker-buildx
# make sure to set IdentityFile /home/pstruebi/.ssh/id_ed25519 in ~/.ssh/config -maybe not nececcary
# example build commands:
# docker build --ssh default .
# docker build --ssh default=~/.ssh/id_rsa .
# docker build --ssh default -t bumble-auracast:latest .
# example run commands
# interactive:
# docker run --rm -it --device /dev/serial/by-id/usb-ZEPHYR_Zephyr_HCI_UART_sample_81BD14B8D71B5662-if00 bumble-auracast bash

19
docker-compose.yaml Normal file
View File

@@ -0,0 +1,19 @@
services:
app:
build:
context: .
dockerfile: Dockerfile
ssh:
- default=~/.ssh/id_ed25519 #lappi
#- default=~/.ssh/id_rsa #raspi
devices:
- /dev/serial/by-id/usb-ZEPHYR_Zephyr_HCI_UART_sample_81BD14B8D71B5662-if00
environment:
LOG_LEVEL: INFO
# - DOCKER_BUILDKIT=1 # Enable BuildKit (can also be passed during build)
#command: python ./auracast/multicast_server.py #devserver
command: python ./auracast/multicast.py # continously streaming test app
# use docker compose up --build

View File

@@ -7,15 +7,16 @@ dependencies = [
"bumble @ git+ssh://git@ssh.pstruebi.xyz:222/auracaster/bumble_mirror.git@12bcdb7770c0d57a094bc0a96cd52e701f97fece", "bumble @ git+ssh://git@ssh.pstruebi.xyz:222/auracaster/bumble_mirror.git@12bcdb7770c0d57a094bc0a96cd52e701f97fece",
"lc3 @ git+ssh://git@ssh.pstruebi.xyz:222/auracaster/liblc3.git@7558637303106c7ea971e7bb8cedf379d3e08bcc", "lc3 @ git+ssh://git@ssh.pstruebi.xyz:222/auracaster/liblc3.git@7558637303106c7ea971e7bb8cedf379d3e08bcc",
"sounddevice", "sounddevice",
"aioconsole" "aioconsole",
"quart == 0.20.0",
] ]
[project.optional-dependencies] [project.optional-dependencies]
test = [ test = [
"pytest >= 8.2", "pytest >= 8.2",
"pytest-asyncio"
] ]
[build-system] [build-system]
requires = ["setuptools>=61", "wheel", "setuptools_scm>=8"] requires = ["setuptools>=61"] #, "wheel", "setuptools_scm>=8"
build-backend = "setuptools.build_meta" build-backend = "setuptools.build_meta"

View File

@@ -54,7 +54,8 @@ class AuracastBigConfig:
program_info: str = 'Some Announcements' program_info: str = 'Some Announcements'
audio_source: str = 'file:./auracast/announcement_48_10_96000_en.wav' audio_source: str = 'file:./auracast/announcement_48_10_96000_en.wav'
input_format: str = 'auto' input_format: str = 'auto'
loop_wav: bool = True loop: bool = True
precode_wav: bool = False
iso_que_len: int = 64 iso_que_len: int = 64
@@ -65,7 +66,7 @@ broadcast_de = AuracastBigConfig(
name = 'Broadcast0', name = 'Broadcast0',
language='deu', language='deu',
program_info = 'Announcements German', program_info = 'Announcements German',
audio_source = 'file:./auracast/announcement_48_10_96000_de.wav', audio_source = 'file:./testdata/announcement_de.wav',
) )
broadcast_en = AuracastBigConfig( broadcast_en = AuracastBigConfig(
@@ -74,7 +75,7 @@ broadcast_en = AuracastBigConfig(
name = 'Broadcast1', name = 'Broadcast1',
language='eng', language='eng',
program_info = 'Announcements English', program_info = 'Announcements English',
audio_source = 'file:./auracast/announcement_48_10_96000_en.wav', audio_source = 'file:./testdata/announcement_en.wav',
) )
broadcast_fr = AuracastBigConfig( broadcast_fr = AuracastBigConfig(
@@ -83,7 +84,7 @@ broadcast_fr = AuracastBigConfig(
name = 'Broadcast2', name = 'Broadcast2',
language='fra', language='fra',
program_info = 'Announcements French', program_info = 'Announcements French',
audio_source = 'file:./auracast/announcement_48_10_96000_fr.wav', audio_source = 'file:./testdata/announcement_fr.wav',
) )
broadcast_es = AuracastBigConfig( broadcast_es = AuracastBigConfig(
@@ -92,7 +93,7 @@ broadcast_es = AuracastBigConfig(
name = 'Broadcast3', name = 'Broadcast3',
language='spa', language='spa',
program_info = 'Announcements Spanish', program_info = 'Announcements Spanish',
audio_source = 'file:./auracast/announcement_48_10_96_es.wav', audio_source = 'file:./testdata/announcement_es.wav',
) )
broadcast_it = AuracastBigConfig( broadcast_it = AuracastBigConfig(
@@ -101,5 +102,5 @@ broadcast_it = AuracastBigConfig(
name = 'Broadcast4', name = 'Broadcast4',
language='ita', language='ita',
program_info = 'Announcements Italian', program_info = 'Announcements Italian',
audio_source = 'file:./auracast/announcement_48_10_96_it.wav', audio_source = 'file:./testdata/announcement_it.wav',
) )

View File

@@ -26,7 +26,6 @@ import struct
from typing import cast, Any, AsyncGenerator, Coroutine, Dict, Optional, Tuple, List from typing import cast, Any, AsyncGenerator, Coroutine, Dict, Optional, Tuple, List
import itertools import itertools
try: try:
import lc3 # type: ignore # pylint: disable=E0401 import lc3 # type: ignore # pylint: disable=E0401
except ImportError as e: except ImportError as e:
@@ -48,6 +47,7 @@ from bumble.device import Host, BIGInfoAdvertisement, AdvertisingChannelMap
from bumble.audio import io as audio_io from bumble.audio import io as audio_io
from auracast import auracast_config from auracast import auracast_config
from auracast.utils.read_lc3_file import read_lc3_file
# modified from bumble # modified from bumble
@@ -94,6 +94,7 @@ class ModWaveAudioInput(audio_io.ThreadedAudioInput):
audio_io.WaveAudioInput = ModWaveAudioInput audio_io.WaveAudioInput = ModWaveAudioInput
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Logging # Logging
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@@ -288,50 +289,31 @@ async def init_broadcast(
return bigs 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(): class Streamer():
def __init__(self, bigs): """
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.task = None
self.is_streaming = False self.is_streaming = False
self.bigs = bigs self.bigs = bigs
self.global_config = global_config
self.big_config = big_config
def start_streaming(self): def start_streaming(self):
if not self.is_streaming: if not self.is_streaming:
@@ -348,21 +330,133 @@ class Streamer():
self.task = None self.task = None
async def stream(self): async def stream(self):
# TODO: do some pre buffering so the stream is stable from the beginning. One half iso queue would be appropriate 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...") logging.info("Streaming audio...")
bigs = self.bigs bigs = self.bigs
self.is_streaming = True self.is_streaming = True
# One streamer fits all
while self.is_streaming: while self.is_streaming:
stream_finished = [False for _ in range(len(bigs))] stream_finished = [False for _ in range(len(bigs))]
for i, big in enumerate(bigs.values()): 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( if big['precoded']:# everything was already lc3 coded beforehand
pcm_frame, num_bytes=big['lc3_bytes_per_frame'], bit_depth=big['pcm_bit_depth'] 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) await big['iso_queue'].write(lc3_frame)
if all(stream_finished): # Take into account that multiple files have different lengths if all(stream_finished): # Take into account that multiple files have different lengths
@@ -387,12 +481,7 @@ async def broadcast(global_conf: auracast_config.AuracastGlobalConfig, big_conf:
global_conf, global_conf,
big_conf big_conf
) )
await init_audio( streamer = Streamer(bigs, global_conf, big_conf)
bigs,
global_conf,
big_conf
)
streamer = Streamer(bigs)
streamer.start_streaming() streamer.start_streaming()
await asyncio.wait([streamer.task]) await asyncio.wait([streamer.task])
@@ -400,10 +489,18 @@ async def broadcast(global_conf: auracast_config.AuracastGlobalConfig, big_conf:
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
if __name__ == "__main__": 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( logging.basicConfig(
level=logging.DEBUG, level=log_level,
format='%(module)s.py:%(lineno)d %(levelname)s: %(message)s' 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 = auracast_config.global_base_config
@@ -429,11 +526,13 @@ if __name__ == "__main__":
#auracast_config.broadcast_es, #auracast_config.broadcast_es,
#auracast_config.broadcast_it, #auracast_config.broadcast_it,
] ]
#for big in bigs: # TODO. investigate this further for big in bigs: # TODO: encrypted streams are not working
# big.code = 'ff'*16 # returns hci/HCI_ENCRYPTION_MODE_NOT_ACCEPTABLE_ERROR #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.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 # 16kHz works reliably with 3 streams
# 24kHz is only working with 2 streams - probably airtime constraint # 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: with more than three broadcasters (16kHz) no advertising (no primary channels is present anymore)

View File

@@ -29,8 +29,15 @@ class Multicaster:
self.big_conf = big_conf self.big_conf = big_conf
self.device = None self.device = None
self.bigs = None self.bigs = None
self.streamer=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): async def init_broadcast(self):
self.device_acm = multicast.create_device(self.global_conf) self.device_acm = multicast.create_device(self.global_conf)
@@ -45,21 +52,14 @@ class Multicaster:
self.device = device self.device = device
self.is_auracast_init = True 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): def start_streaming(self):
self.streamer = multicast.Streamer(self.bigs, self.global_conf, self.big_conf)
self.streamer.start_streaming() self.streamer.start_streaming()
def stop_streaming(self): def stop_streaming(self):
if self.streamer is not None: if self.streamer is not None:
self.streamer.stop_streaming() self.streamer.stop_streaming()
self.streamer = None
async def reset(self): async def reset(self):
await self.shutdown() # Manually triggering teardown await self.shutdown() # Manually triggering teardown
@@ -70,12 +70,12 @@ class Multicaster:
self. is_audio_init = False self. is_audio_init = False
if self.device: if self.device:
await self.device.stop_advertising() await self.device.stop_advertising()
if self.bigs: if self.bigs:
for big in self.bigs.values(): for big in self.bigs.values():
if big['advertising_set']: if big['advertising_set']:
await big['advertising_set'].stop() await big['advertising_set'].stop()
await self.device_acm.__aexit__(None, None, None) # Manually triggering teardown
await self.device_acm.__aexit__(None, None, None) # Manually triggering teardown
# example commandline ui # example commandline ui
@@ -100,7 +100,6 @@ async def command_line_ui(caster: Multicaster):
await caster.reset() await caster.reset()
await caster.init_broadcast() await caster.init_broadcast()
await caster.init_audio() await caster.init_audio()
elif command.strip().lower() == "init_audio": elif command.strip().lower() == "init_audio":
await caster.init_audio() await caster.init_audio()
@@ -131,7 +130,7 @@ async def main():
#auracast_config.broadcast_it, #auracast_config.broadcast_it,
] ]
for conf in big_conf: for conf in big_conf:
conf.loop_wav = False conf.loop = False
# look into: # look into:
#async with MyAPI() as api: #async with MyAPI() as api:
@@ -139,7 +138,6 @@ async def main():
caster = Multicaster(global_conf, big_conf) caster = Multicaster(global_conf, big_conf)
await caster.init_broadcast() await caster.init_broadcast()
await caster.init_audio()
await command_line_ui(caster) await command_line_ui(caster)

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)

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

74
tests/test_server.py Normal file
View File

@@ -0,0 +1,74 @@
import pytest
import pytest_asyncio
import asyncio
from quart import jsonify
from auracast.utils.read_lc3_file import read_lc3_file
from auracast import multicast_server
@pytest.fixture
def client():
"""Fixture to create and return a Quart test client."""
yield multicast_server.app.test_client()
@pytest_asyncio.fixture
async def init_broadcast(client):
response = await client.post('/init')
json_data = await response.get_json()
assert response.status_code == 200
assert json_data["status"] == "initialized"
@pytest.mark.asyncio
async def test_shutdown(client, init_broadcast):
"""Tests the /shutdown endpoint."""
response = await client.post('/shutdown')
json_data = await response.get_json()
assert response.status_code == 200
assert json_data["status"] == "stopped"
@pytest.mark.asyncio
async def test_stop_audio(client, init_broadcast):
"""Tests the /stop_audio endpoint."""
response = await client.post('/stop_audio')
json_data = await response.get_json()
assert response.status_code == 200
assert json_data["status"] == "stopped"
@pytest.mark.asyncio
async def test_send_audio(client, init_broadcast):
"""Tests the /stream_lc3 endpoint."""
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 = await client.post('/stream_lc3', json=test_audio_data)
json_data = await response.get_json()
assert response.status_code == 200
assert json_data["status"] == "audio_sent"
await asyncio.wait([multicast_server.multicaster.streamer.task], timeout=10)
# Ensure the audio data is correctly assigned
for key, val in multicast_server.big_conf.items():
if key in test_audio_data:
assert val.audio_source == test_audio_data[key].encode('latin-1')
else:
assert val.audio_source == b""
@pytest.mark.asyncio
async def test_get_status(client):
"""Tests the /status endpoint."""
response = await client.get('/status')
json_data = await response.get_json()
assert response.status_code == 200
assert "status" in json_data