diff --git a/.vscode/tasks.json b/.vscode/tasks.json index fb1d702..e2c55d4 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -3,10 +3,15 @@ // for the documentation about the tasks.json format "version": "2.0.0", "tasks": [ + { + "label": "Setup project for development", + "type": "shell", + "command": "./venv/bin/python -m pip install -e ." + }, { "label": "pip install -e bumble", "type": "shell", "command": "./venv/bin/python -m pip install -e ../bumble --config-settings editable_mode=compat" - } + }, ] } \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..d7619cc --- /dev/null +++ b/Dockerfile @@ -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 diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..ec410af --- /dev/null +++ b/docker-compose.yaml @@ -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 \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 431e6fa..0fbc98b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,15 +7,16 @@ dependencies = [ "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", "sounddevice", - "aioconsole" + "aioconsole", + "quart == 0.20.0", ] [project.optional-dependencies] test = [ "pytest >= 8.2", + "pytest-asyncio" ] - [build-system] -requires = ["setuptools>=61", "wheel", "setuptools_scm>=8"] +requires = ["setuptools>=61"] #, "wheel", "setuptools_scm>=8" build-backend = "setuptools.build_meta" diff --git a/auracast/__init__.py b/src/auracast/__init__.py similarity index 100% rename from auracast/__init__.py rename to src/auracast/__init__.py diff --git a/auracast/auracast_config.py b/src/auracast/auracast_config.py similarity index 89% rename from auracast/auracast_config.py rename to src/auracast/auracast_config.py index facba53..63bc690 100644 --- a/auracast/auracast_config.py +++ b/src/auracast/auracast_config.py @@ -54,7 +54,8 @@ class AuracastBigConfig: program_info: str = 'Some Announcements' audio_source: str = 'file:./auracast/announcement_48_10_96000_en.wav' input_format: str = 'auto' - loop_wav: bool = True + loop: bool = True + precode_wav: bool = False iso_que_len: int = 64 @@ -65,7 +66,7 @@ broadcast_de = AuracastBigConfig( name = 'Broadcast0', language='deu', program_info = 'Announcements German', - audio_source = 'file:./auracast/announcement_48_10_96000_de.wav', + audio_source = 'file:./testdata/announcement_de.wav', ) broadcast_en = AuracastBigConfig( @@ -74,7 +75,7 @@ broadcast_en = AuracastBigConfig( name = 'Broadcast1', language='eng', program_info = 'Announcements English', - audio_source = 'file:./auracast/announcement_48_10_96000_en.wav', + audio_source = 'file:./testdata/announcement_en.wav', ) broadcast_fr = AuracastBigConfig( @@ -83,7 +84,7 @@ broadcast_fr = AuracastBigConfig( name = 'Broadcast2', language='fra', program_info = 'Announcements French', - audio_source = 'file:./auracast/announcement_48_10_96000_fr.wav', + audio_source = 'file:./testdata/announcement_fr.wav', ) broadcast_es = AuracastBigConfig( @@ -92,7 +93,7 @@ broadcast_es = AuracastBigConfig( name = 'Broadcast3', language='spa', program_info = 'Announcements Spanish', - audio_source = 'file:./auracast/announcement_48_10_96_es.wav', + audio_source = 'file:./testdata/announcement_es.wav', ) broadcast_it = AuracastBigConfig( @@ -101,5 +102,5 @@ broadcast_it = AuracastBigConfig( name = 'Broadcast4', language='ita', program_info = 'Announcements Italian', - audio_source = 'file:./auracast/announcement_48_10_96_it.wav', + audio_source = 'file:./testdata/announcement_it.wav', ) \ No newline at end of file diff --git a/auracast/multicast.py b/src/auracast/multicast.py similarity index 68% rename from auracast/multicast.py rename to src/auracast/multicast.py index 87e1b3d..554680d 100644 --- a/auracast/multicast.py +++ b/src/auracast/multicast.py @@ -26,7 +26,6 @@ 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: @@ -48,6 +47,7 @@ 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 @@ -94,6 +94,7 @@ class ModWaveAudioInput(audio_io.ThreadedAudioInput): audio_io.WaveAudioInput = ModWaveAudioInput + # ----------------------------------------------------------------------------- # Logging # ----------------------------------------------------------------------------- @@ -288,50 +289,31 @@ async def init_broadcast( return bigs -async def init_audio( - bigs, - global_config : auracast_config.AuracastGlobalConfig, - big_config: List[auracast_config.AuracastBigConfig] - ): - for i, big in enumerate(bigs.values()): - audio_source = big_config[i].audio_source - input_format = big_config[i].input_format - audio_input = await audio_io.create_audio_input(audio_source, input_format) - audio_input.rewind = big_config[i].loop_wav - pcm_format = await audio_input.open() - - #try: - if pcm_format.channels != 1: - print("Only 1 channels PCM configurations are supported") - return - if pcm_format.sample_type == audio_io.PcmFormat.SampleType.INT16: - pcm_bit_depth = 16 - elif pcm_format.sample_type == audio_io.PcmFormat.SampleType.FLOAT32: - pcm_bit_depth = None - else: - print("Only INT16 and FLOAT32 sample types are supported") - return - encoder = lc3.Encoder( - frame_duration_us=global_config.frame_duration_us, - sample_rate_hz=global_config.auracast_sampling_rate_hz, - num_channels=1, - input_sample_rate_hz=pcm_format.sample_rate, - ) - lc3_frame_samples = encoder.get_frame_samples() # number of the pcm samples per lc3 frame - lc3_frame_size = global_config.octets_per_frame #encoder.get_frame_bytes(bitrate) - lc3_bytes_per_frame = lc3_frame_size #* 2 #multiplied by number of channels - - big['pcm_bit_depth'] = pcm_bit_depth - big['lc3_bytes_per_frame'] = lc3_bytes_per_frame - big['lc3_frame_samples'] = lc3_frame_samples - big['audio_input'] = audio_input - big['encoder'] = encoder class Streamer(): - def __init__(self, bigs): + """ + 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: @@ -348,21 +330,133 @@ class Streamer(): self.task = None async def stream(self): - # TODO: do some pre buffering so the stream is stable from the beginning. One half iso queue would be appropriate + 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()): - 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'] - ) + 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 @@ -387,12 +481,7 @@ async def broadcast(global_conf: auracast_config.AuracastGlobalConfig, big_conf: global_conf, big_conf ) - await init_audio( - bigs, - global_conf, - big_conf - ) - streamer = Streamer(bigs) + streamer = Streamer(bigs, global_conf, big_conf) streamer.start_streaming() await asyncio.wait([streamer.task]) @@ -400,10 +489,18 @@ async def broadcast(global_conf: auracast_config.AuracastGlobalConfig, big_conf: # ----------------------------------------------------------------------------- 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=logging.DEBUG, + 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 @@ -429,11 +526,13 @@ if __name__ == "__main__": #auracast_config.broadcast_es, #auracast_config.broadcast_it, ] - #for big in bigs: # TODO. investigate this further - # big.code = 'ff'*16 # returns hci/HCI_ENCRYPTION_MODE_NOT_ACCEPTABLE_ERROR + 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) diff --git a/auracast/multicast_control.py b/src/auracast/multicast_control.py similarity index 85% rename from auracast/multicast_control.py rename to src/auracast/multicast_control.py index 5226fcf..8bc3e4d 100644 --- a/auracast/multicast_control.py +++ b/src/auracast/multicast_control.py @@ -29,8 +29,15 @@ class Multicaster: self.big_conf = big_conf self.device = 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): self.device_acm = multicast.create_device(self.global_conf) @@ -45,21 +52,14 @@ class Multicaster: self.device = device self.is_auracast_init = True - async def init_audio(self): - await multicast.init_audio( - self.bigs, - self.global_conf, - self.big_conf - ) - self.is_audio_init = True - self.streamer = multicast.Streamer(self.bigs) - def start_streaming(self): + self.streamer = 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 @@ -70,12 +70,12 @@ class Multicaster: 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() + 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 - await self.device_acm.__aexit__(None, None, None) # Manually triggering teardown # example commandline ui @@ -100,7 +100,6 @@ async def command_line_ui(caster: Multicaster): await caster.reset() await caster.init_broadcast() await caster.init_audio() - elif command.strip().lower() == "init_audio": await caster.init_audio() @@ -131,7 +130,7 @@ async def main(): #auracast_config.broadcast_it, ] for conf in big_conf: - conf.loop_wav = False + conf.loop = False # look into: #async with MyAPI() as api: @@ -139,7 +138,6 @@ async def main(): caster = Multicaster(global_conf, big_conf) await caster.init_broadcast() - await caster.init_audio() await command_line_ui(caster) diff --git a/src/auracast/multicast_control_client.py b/src/auracast/multicast_control_client.py new file mode 100644 index 0000000..8751564 --- /dev/null +++ b/src/auracast/multicast_control_client.py @@ -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()) diff --git a/src/auracast/multicast_server.py b/src/auracast/multicast_server.py new file mode 100644 index 0000000..ece15a5 --- /dev/null +++ b/src/auracast/multicast_server.py @@ -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) \ No newline at end of file diff --git a/auracast/run_btmon_rtt.sh b/src/auracast/run_btmon_rtt.sh similarity index 100% rename from auracast/run_btmon_rtt.sh rename to src/auracast/run_btmon_rtt.sh diff --git a/auracast/announcement_48_10_96000_de.wav b/src/auracast/testdata/announcement_de.wav similarity index 100% rename from auracast/announcement_48_10_96000_de.wav rename to src/auracast/testdata/announcement_de.wav diff --git a/src/auracast/testdata/announcement_de_10_16_32.lc3 b/src/auracast/testdata/announcement_de_10_16_32.lc3 new file mode 100644 index 0000000..f438354 Binary files /dev/null and b/src/auracast/testdata/announcement_de_10_16_32.lc3 differ diff --git a/auracast/announcement_48_10_96000_en.wav b/src/auracast/testdata/announcement_en.wav similarity index 100% rename from auracast/announcement_48_10_96000_en.wav rename to src/auracast/testdata/announcement_en.wav diff --git a/src/auracast/testdata/announcement_en_10_16_32.lc3 b/src/auracast/testdata/announcement_en_10_16_32.lc3 new file mode 100644 index 0000000..cc224da Binary files /dev/null and b/src/auracast/testdata/announcement_en_10_16_32.lc3 differ diff --git a/auracast/announcement_48_10_96000_en_stereo.wav b/src/auracast/testdata/announcement_en_stereo.wav similarity index 100% rename from auracast/announcement_48_10_96000_en_stereo.wav rename to src/auracast/testdata/announcement_en_stereo.wav diff --git a/src/auracast/testdata/announcement_en_stereo_10_16_32.lc3 b/src/auracast/testdata/announcement_en_stereo_10_16_32.lc3 new file mode 100644 index 0000000..7dc7f02 Binary files /dev/null and b/src/auracast/testdata/announcement_en_stereo_10_16_32.lc3 differ diff --git a/auracast/announcement_48_10_96_es.wav b/src/auracast/testdata/announcement_es.wav similarity index 100% rename from auracast/announcement_48_10_96_es.wav rename to src/auracast/testdata/announcement_es.wav diff --git a/src/auracast/testdata/announcement_es_10_16_32.lc3 b/src/auracast/testdata/announcement_es_10_16_32.lc3 new file mode 100644 index 0000000..bc0856c Binary files /dev/null and b/src/auracast/testdata/announcement_es_10_16_32.lc3 differ diff --git a/auracast/announcement_48_10_96000_fr.wav b/src/auracast/testdata/announcement_fr.wav similarity index 100% rename from auracast/announcement_48_10_96000_fr.wav rename to src/auracast/testdata/announcement_fr.wav diff --git a/src/auracast/testdata/announcement_fr_10_16_32.lc3 b/src/auracast/testdata/announcement_fr_10_16_32.lc3 new file mode 100644 index 0000000..e07bad0 Binary files /dev/null and b/src/auracast/testdata/announcement_fr_10_16_32.lc3 differ diff --git a/auracast/announcement_48_10_96_it.wav b/src/auracast/testdata/announcement_it.wav similarity index 100% rename from auracast/announcement_48_10_96_it.wav rename to src/auracast/testdata/announcement_it.wav diff --git a/src/auracast/testdata/announcement_it_10_16_32.lc3 b/src/auracast/testdata/announcement_it_10_16_32.lc3 new file mode 100644 index 0000000..4034f5d Binary files /dev/null and b/src/auracast/testdata/announcement_it_10_16_32.lc3 differ diff --git a/src/auracast/testdata/encode_lc3.py b/src/auracast/testdata/encode_lc3.py new file mode 100644 index 0000000..a67cd8e --- /dev/null +++ b/src/auracast/testdata/encode_lc3.py @@ -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) diff --git a/src/auracast/utils/__init__.py b/src/auracast/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/auracast/utils/read_lc3_file.py b/src/auracast/utils/read_lc3_file.py new file mode 100644 index 0000000..d075d07 --- /dev/null +++ b/src/auracast/utils/read_lc3_file.py @@ -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 \ No newline at end of file diff --git a/tests/test_server.py b/tests/test_server.py new file mode 100644 index 0000000..fa8cfa0 --- /dev/null +++ b/tests/test_server.py @@ -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 \ No newline at end of file