diff --git a/.gitignore b/.gitignore index 22923a2..32b1bbf 100644 --- a/.gitignore +++ b/.gitignore @@ -4,7 +4,6 @@ coverage/ # Coverage results after running tests with coverage tools .dist-info/ # Wheel metadata (use poetry build to handle this) *.egg-info/ # Egg info directory (automatically created by pip) auracast.egg-info/ -.vscode/ # IDE configuration (edit in VS Code) # Ignore these file types and extensions *.pyc # Compiled Python files (.pyc, .pyo are automatically ignored by git) @@ -16,11 +15,11 @@ venv/ env/ # Ignore any IDE configurations or project-specific metadata -.vscode/** .pycharm/** *.iml .project .settings +.vscode/settings.json # Ignore test results and logs (adjust to your specific testing framework) /testresults/** @@ -35,4 +34,5 @@ env/ __pycache__/ # Exclude .env file from all platforms -*/.env \ No newline at end of file +*/.env + diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..8f20a7e --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,19 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Python Debugger: current file", + "type": "debugpy", + "request": "launch", + "program": "${file}", + "console": "integratedTerminal", + "justMyCode": true, + "args": [ + + ] + } + ] +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..fb1d702 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,12 @@ +{ + // See https://go.microsoft.com/fwlink/?LinkId=733558 + // for the documentation about the tasks.json format + "version": "2.0.0", + "tasks": [ + { + "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/auracast/announcement_48_10_96000_en_stereo.wav b/auracast/announcement_48_10_96000_en_stereo.wav new file mode 100644 index 0000000..7304827 Binary files /dev/null and b/auracast/announcement_48_10_96000_en_stereo.wav differ diff --git a/auracast/announcement_48_10_96_es.wav b/auracast/announcement_48_10_96_es.wav new file mode 100644 index 0000000..181b38a Binary files /dev/null and b/auracast/announcement_48_10_96_es.wav differ diff --git a/auracast/announcement_48_10_96_it.wav b/auracast/announcement_48_10_96_it.wav new file mode 100644 index 0000000..7c800b1 Binary files /dev/null and b/auracast/announcement_48_10_96_it.wav differ diff --git a/auracast/auracast.py b/auracast/auracast.py deleted file mode 100644 index eff332b..0000000 --- a/auracast/auracast.py +++ /dev/null @@ -1,269 +0,0 @@ -# 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 asyncio -import contextlib -import logging -import wave -import itertools -from typing import cast, Any, AsyncGenerator, Coroutine, Dict, Optional, Tuple -from typing import List - - -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 - -import auracast_config - - -def modified_on_hci_number_of_completed_packets_event(self, event): - for connection_handle, num_completed_packets in zip( - event.connection_handles, event.num_completed_packets - ): - if connection := self.connections.get(connection_handle): - connection.acl_packet_queue.on_packets_completed(num_completed_packets) - elif connection_handle not in itertools.chain( - self.cis_links.keys(), - self.sco_links.keys(), - itertools.chain.from_iterable(self.bigs.values()), - ): - logger.warning( - 'received packet completion event for unknown handle ' - f'0x{connection_handle:04X}' - ) - self.emit('hci_number_of_completed_packets_event', event) - -Host.on_hci_number_of_completed_packets_event = modified_on_hci_number_of_completed_packets_event - - - -# ----------------------------------------------------------------------------- -# 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', - ) - - 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 run_broadcast( - global_config : auracast_config.AuracastGlobalConfig, - big_config: List[auracast_config.AuracastBigConfig] - -) -> None: - async with create_device(global_config) as device: - if not device.supports_le_periodic_advertising: - logger.error(color('Periodic advertising not supported', 'red')) - return - - with wave.open(big_config[0].broacast_wav_file_path, 'rb') as wav: - logger.info('Encoding wav file into lc3...') - logger.info('Frame rate of .wav file is: %s', wav.getframerate()) - encoder = lc3.Encoder( - frame_duration_us=global_config.frame_duration_us, - sample_rate_hz=global_config.auracast_sampling_rate_khz, - num_channels=1, - input_sample_rate_hz=wav.getframerate(), - ) - frames = list[bytes]() - while pcm := wav.readframes(encoder.get_frame_samples()): - frames.append( - encoder.encode(pcm, num_bytes=global_config.octets_per_frame, bit_depth=wav.getsampwidth() * 8) - ) - del encoder - print('Encoding complete.') - - # Config advertising set - bap_sampling_freq = getattr(bap.SamplingFrequency, f"FREQ_{global_config.auracast_sampling_rate_khz}") - 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=big_config[0].broadcast_language.encode() - ), - le_audio.Metadata.Entry( - tag=le_audio.Metadata.Tag.PROGRAM_INFO, data=big_config[0].broadcast_program_info.encode() - ), - ] - ), - bis=[ - bap.BasicAudioAnnouncement.BIS( - index=1, - codec_specific_configuration=bap.CodecSpecificConfiguration( - audio_channel_allocation=bap.AudioLocation.FRONT_LEFT - ), - ), - ], - ) - ], - ) - logging.info('Setup Advertising') - broadcast_audio_announcement = bap.BroadcastAudioAnnouncement(big_config[0].broadcast_id) - advertising_set0 = await device.create_advertising_set( - 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=0 - # TODO: use 2mbit phy - ), - advertising_data=( - broadcast_audio_announcement.get_advertising_data() - + bytes( - core.AdvertisingData( - [(core.AdvertisingData.BROADCAST_NAME, big_config[0].broadcast_name.encode())] - ) - ) - ), - periodic_advertising_parameters=bumble.device.PeriodicAdvertisingParameters( - periodic_advertising_interval_min=80, - periodic_advertising_interval_max=160, - ), - periodic_advertising_data=basic_audio_announcement.get_advertising_data(), - auto_restart=True, - auto_start=True, - ) - - logging.info('Start Periodic Advertising') - await advertising_set0.start_periodic() - - logging.info('Setup BIG') - big0 = await device.create_big( - advertising_set0, - parameters=bumble.device.BigParameters( - num_bis=1, - sdu_interval=global_config.frame_duration_us, - max_sdu=global_config.octets_per_frame, # is this octets per frame ? - max_transport_latency=65, - rtn=4, - broadcast_code=( - bytes.fromhex(big_config[0].broadcast_code) if big_config[0].broadcast_code else None - ), - ), - ) - - logging.info('Setup ISO Data Path') - for bis_link in big0.bis_links: - await bis_link.setup_data_path( - direction=bis_link.Direction.HOST_TO_CONTROLLER - ) - - frames_iterator = itertools.cycle(frames) - logging.info("Broadcasting...") - - def on_packet_complete(event): - frame = next(frames_iterator) - big0.bis_links[0].write(frame) - - device.host.on('hci_number_of_completed_packets_event', on_packet_complete) - - on_packet_complete('') # Send the first packet, to get the event loop running - - while True: - await asyncio.sleep(1) - -# ----------------------------------------------------------------------------- -# Main -# ----------------------------------------------------------------------------- - -def broadcast(global_conf: auracast_config.AuracastGlobalConfig, big_conf: List[auracast_config.AuracastBigConfig]): - """Start a broadcast as a source.""" - run_async( - run_broadcast( - global_conf, - big_conf - ) - ) - - - -# ----------------------------------------------------------------------------- -if __name__ == "__main__": - logging.basicConfig(level=logging.INFO) - - global_conf = auracast_config.global_base_config - bigs = [ - auracast_config.broadcast_de - ] - global_conf.octets_per_frame=60# 48kbps@24kHz - - broadcast( - global_conf, - bigs - ) diff --git a/auracast/auracast_config.py b/auracast/auracast_config.py index 347190e..facba53 100644 --- a/auracast/auracast_config.py +++ b/auracast/auracast_config.py @@ -3,46 +3,103 @@ 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 = 'serial:/dev/ttyACM1,1000000,rtscts' + transport: str = '' auracast_device_address: hci.Address = hci.Address('F0:F1:F2:F3:F4:F5') - auracast_sampling_rate_khz: int =24000 - octets_per_frame: int = 100 # bitrate = octets_per_frame * 8 / frame len + 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 + 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:' -> 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: -> read audio from a .wav or raw int16 PCM file. " @dataclass class AuracastBigConfig: - broadcast_id: int =123456 - broadcast_code: str = None # a hexstr - broadcast_language: str = 'en' - broadcast_name: str = 'Broadcast0' - broadcast_program_info: str = 'Some Announcements' - broacast_wav_file_path: str = './auracast/announcement_48_10_96000_en.wav' + 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_wav: bool = True + iso_que_len: int = 64 -global_base_config = AuracastGlobalConfig() # Instanciate some example configurations broadcast_de = AuracastBigConfig( - broadcast_id=12, - broadcast_language='de', - broadcast_name = 'Broadcast0', - broacast_wav_file_path = './auracast/announcement_48_10_96000_de.wav', + id=12, + random_address=hci.Address('F1:F1:F2:F3:F4:F5'), + name = 'Broadcast0', + language='deu', + program_info = 'Announcements German', + audio_source = 'file:./auracast/announcement_48_10_96000_de.wav', ) broadcast_en = AuracastBigConfig( - broadcast_id=123, - broadcast_language='eng', - broadcast_name = 'Broadcast1', - broacast_wav_file_path = './auracast/announcement_48_10_96000_en.wav', + id=123, + random_address=hci.Address('F2:F1:F2:F3:F4:F5'), + name = 'Broadcast1', + language='eng', + program_info = 'Announcements English', + audio_source = 'file:./auracast/announcement_48_10_96000_en.wav', ) broadcast_fr = AuracastBigConfig( - broadcast_id=1234, - broadcast_language='fr', - broadcast_name = 'Broadcast2', - broacast_wav_file_path = './auracast/announcement_48_10_96000_fr.wav', + id=1234, + random_address=hci.Address('F3:F1:F2:F3:F4:F5'), + name = 'Broadcast2', + language='fra', + program_info = 'Announcements French', + audio_source = 'file:./auracast/announcement_48_10_96000_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:./auracast/announcement_48_10_96_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:./auracast/announcement_48_10_96_it.wav', ) \ No newline at end of file diff --git a/auracast/multicast.py b/auracast/multicast.py new file mode 100644 index 0000000..87e1b3d --- /dev/null +++ b/auracast/multicast.py @@ -0,0 +1,459 @@ +# 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 + + +# 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(' ") + + 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_wav = False + + # look into: + #async with MyAPI() as api: + #pass + + caster = Multicaster(global_conf, big_conf) + await caster.init_broadcast() + await caster.init_audio() + + await command_line_ui(caster) + +if __name__ == '__main__': + # Run the application + asyncio.run(main()) \ No newline at end of file diff --git a/auracast/run_btmon_rtt.sh b/auracast/run_btmon_rtt.sh new file mode 100644 index 0000000..ce25318 --- /dev/null +++ b/auracast/run_btmon_rtt.sh @@ -0,0 +1 @@ +btmon --jlink NRF54L15_M33,1057705357 \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 52bd2e9..431e6fa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,8 +4,10 @@ version = "0.0.1" requires-python = ">=3.8" dependencies = [ - "bumble @ git+ssh://git@hinterwaldner.duckdns.org:222/auracaster/bumble.git@af466c29704d4d47b291e697b1990621c0b7d86b", - "lc3 @ git+https://github.com/google/liblc3.git@7558637303106c7ea971e7bb8cedf379d3e08bcc", + "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" ] [project.optional-dependencies]