diff --git a/auracast/__init__.py b/docker/Dockerfile similarity index 100% rename from auracast/__init__.py rename to docker/Dockerfile diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml new file mode 100644 index 0000000..e69de29 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/src/auracast/__init__.py b/src/auracast/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/auracast/auracast_config.py b/src/auracast/auracast_config.py similarity index 90% rename from auracast/auracast_config.py rename to src/auracast/auracast_config.py index ef036e3..63bc690 100644 --- a/auracast/auracast_config.py +++ b/src/auracast/auracast_config.py @@ -66,7 +66,7 @@ broadcast_de = AuracastBigConfig( name = 'Broadcast0', language='deu', program_info = 'Announcements German', - audio_source = 'file:./auracast/testdata/announcement_de.wav', + audio_source = 'file:./testdata/announcement_de.wav', ) broadcast_en = AuracastBigConfig( @@ -75,7 +75,7 @@ broadcast_en = AuracastBigConfig( name = 'Broadcast1', language='eng', program_info = 'Announcements English', - audio_source = 'file:./auracast/testdata/announcement_en.wav', + audio_source = 'file:./testdata/announcement_en.wav', ) broadcast_fr = AuracastBigConfig( @@ -84,7 +84,7 @@ broadcast_fr = AuracastBigConfig( name = 'Broadcast2', language='fra', program_info = 'Announcements French', - audio_source = 'file:./auracast/testdata/announcement_fr.wav', + audio_source = 'file:./testdata/announcement_fr.wav', ) broadcast_es = AuracastBigConfig( @@ -93,7 +93,7 @@ broadcast_es = AuracastBigConfig( name = 'Broadcast3', language='spa', program_info = 'Announcements Spanish', - audio_source = 'file:./auracast/testdata/announcement_es.wav', + audio_source = 'file:./testdata/announcement_es.wav', ) broadcast_it = AuracastBigConfig( @@ -102,5 +102,5 @@ broadcast_it = AuracastBigConfig( name = 'Broadcast4', language='ita', program_info = 'Announcements Italian', - audio_source = 'file:./auracast/testdata/announcement_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 88% rename from auracast/multicast.py rename to src/auracast/multicast.py index 5dcdcec..940c6e9 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 @@ -95,35 +95,6 @@ class ModWaveAudioInput(audio_io.ThreadedAudioInput): audio_io.WaveAudioInput = ModWaveAudioInput -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 - # ----------------------------------------------------------------------------- # Logging # ----------------------------------------------------------------------------- @@ -318,51 +289,6 @@ 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 - - if big_config[i].audio_source.endswith('.lc3'): - pass - elif big_config[i].precode_wav: - pass - 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 - 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(): """ @@ -563,10 +489,13 @@ async def broadcast(global_conf: auracast_config.AuracastGlobalConfig, big_conf: # ----------------------------------------------------------------------------- if __name__ == "__main__": + import os + logging.basicConfig( level=logging.DEBUG, format='%(module)s.py:%(lineno)d %(levelname)s: %(message)s' ) + os.chdir(os.path.dirname(__file__)) global_conf = auracast_config.global_base_config diff --git a/auracast/multicast_control.py b/src/auracast/multicast_control.py similarity index 86% rename from auracast/multicast_control.py rename to src/auracast/multicast_control.py index 6416266..1096b01 100644 --- a/auracast/multicast_control.py +++ b/src/auracast/multicast_control.py @@ -29,8 +29,16 @@ 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 +53,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 +71,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 +101,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() @@ -139,7 +139,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..3dc0ec8 --- /dev/null +++ b/src/auracast/multicast_control_client.py @@ -0,0 +1,34 @@ +import requests + +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(): + audio_data = { + "broadcast_de": "test_audio_data_de", + "broadcast_fr": "test_audio_data_fr" + } + response = requests.post(f"{BASE_URL}/stream_lc3", json=audio_data) + return response.json() + +def get_status(): + response = requests.get(f"{BASE_URL}/status") + return response.json() + +if __name__ == "__main__": + print("Initializing server:", initialize()) + print("Sending audio:", send_audio()) + print("Getting status:", get_status()) + print("Stopping audio:", stop_audio()) + print("Shutting down:", shutdown()) \ No newline at end of file diff --git a/src/auracast/multicast_server.py b/src/auracast/multicast_server.py new file mode 100644 index 0000000..aa16b66 --- /dev/null +++ b/src/auracast/multicast_server.py @@ -0,0 +1,92 @@ +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'] + 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/testdata/announcement_de.wav b/src/auracast/testdata/announcement_de.wav similarity index 100% rename from auracast/testdata/announcement_de.wav rename to src/auracast/testdata/announcement_de.wav diff --git a/auracast/testdata/announcement_de_10_16_32.lc3 b/src/auracast/testdata/announcement_de_10_16_32.lc3 similarity index 100% rename from auracast/testdata/announcement_de_10_16_32.lc3 rename to src/auracast/testdata/announcement_de_10_16_32.lc3 diff --git a/auracast/testdata/announcement_en.wav b/src/auracast/testdata/announcement_en.wav similarity index 100% rename from auracast/testdata/announcement_en.wav rename to src/auracast/testdata/announcement_en.wav diff --git a/auracast/testdata/announcement_en_10_16_32.lc3 b/src/auracast/testdata/announcement_en_10_16_32.lc3 similarity index 100% rename from auracast/testdata/announcement_en_10_16_32.lc3 rename to src/auracast/testdata/announcement_en_10_16_32.lc3 diff --git a/auracast/testdata/announcement_en_stereo.wav b/src/auracast/testdata/announcement_en_stereo.wav similarity index 100% rename from auracast/testdata/announcement_en_stereo.wav rename to src/auracast/testdata/announcement_en_stereo.wav diff --git a/auracast/testdata/announcement_en_stereo_10_16_32.lc3 b/src/auracast/testdata/announcement_en_stereo_10_16_32.lc3 similarity index 100% rename from auracast/testdata/announcement_en_stereo_10_16_32.lc3 rename to src/auracast/testdata/announcement_en_stereo_10_16_32.lc3 diff --git a/auracast/testdata/announcement_es.wav b/src/auracast/testdata/announcement_es.wav similarity index 100% rename from auracast/testdata/announcement_es.wav rename to src/auracast/testdata/announcement_es.wav diff --git a/auracast/testdata/announcement_es_10_16_32.lc3 b/src/auracast/testdata/announcement_es_10_16_32.lc3 similarity index 100% rename from auracast/testdata/announcement_es_10_16_32.lc3 rename to src/auracast/testdata/announcement_es_10_16_32.lc3 diff --git a/auracast/testdata/announcement_fr.wav b/src/auracast/testdata/announcement_fr.wav similarity index 100% rename from auracast/testdata/announcement_fr.wav rename to src/auracast/testdata/announcement_fr.wav diff --git a/auracast/testdata/announcement_fr_10_16_32.lc3 b/src/auracast/testdata/announcement_fr_10_16_32.lc3 similarity index 100% rename from auracast/testdata/announcement_fr_10_16_32.lc3 rename to src/auracast/testdata/announcement_fr_10_16_32.lc3 diff --git a/auracast/testdata/announcement_it.wav b/src/auracast/testdata/announcement_it.wav similarity index 100% rename from auracast/testdata/announcement_it.wav rename to src/auracast/testdata/announcement_it.wav diff --git a/auracast/testdata/announcement_it_10_16_32.lc3 b/src/auracast/testdata/announcement_it_10_16_32.lc3 similarity index 100% rename from auracast/testdata/announcement_it_10_16_32.lc3 rename to src/auracast/testdata/announcement_it_10_16_32.lc3 diff --git a/auracast/testdata/encode_lc3.py b/src/auracast/testdata/encode_lc3.py similarity index 100% rename from auracast/testdata/encode_lc3.py rename to src/auracast/testdata/encode_lc3.py 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..0aa0796 --- /dev/null +++ b/tests/test_server.py @@ -0,0 +1,71 @@ +import pytest +import asyncio +from quart import jsonify +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.mark.asyncio +async def test_initialize(client): + """Tests the /init endpoint.""" + #client = multicast_server.app.test_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): + """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): + """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): + """Tests the /stream_lc3 endpoint.""" + test_audio_data = { + "broadcast_de": read_lc3_file(big.audio_source), + "broadcast_fr": b"test_audio_data_fr" + } + + 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" + + # 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] + 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