commit fd0231690bcc9d4d5b4263c1cb98e14f9a69e950 Author: Logan Cusano Date: Sat Jan 25 02:31:27 2025 -0500 Init repo diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dd4113b --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +__pycache__* +bot-poc.py +configs* +.env \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..eec30ef --- /dev/null +++ b/Dockerfile @@ -0,0 +1,69 @@ +## OP25 Core Container +FROM ubuntu:22.04 + +# Set environment variables +ENV DEBIAN_FRONTEND=noninteractive + +# Install system dependencies +RUN apt-get update && \ + apt-get install -y git \ + curl \ + python3 \ + python3-pip \ + build-essential \ + cmake \ + libuhd-dev \ + uhd-host \ + libvolk2-dev \ + libsndfile1-dev \ + pkg-config \ + sudo \ + libsndfile1 \ + pulseaudio \ + libasound-dev \ + portaudio19-dev \ + libportaudio2 \ + libpulse-dev \ + apulse \ + ffmpeg + +# Clone the boatbod op25 repository +RUN git clone -b gr310 https://github.com/boatbod/op25 /op25 + +# Set the working directory +WORKDIR /op25 + +# Run the install script to set up op25 +RUN ./install.sh -f + +# Install Python dependencies +COPY requirements.txt /tmp/requirements.txt +RUN pip3 install --no-cache-dir -r /tmp/requirements.txt + +# Create the run_multi-rx_service.sh script +RUN echo "#!/bin/bash\n./multi_rx.py -v 1 -c /configs/active.cfg.json" > ./op25/gr-op25_repeater/apps/run_multi-rx_service.sh && \ + chmod +x ./op25/gr-op25_repeater/apps/run_multi-rx_service.sh + +# Setup Pulseaudio for the container +RUN systemctl --global disable pulseaudio.service pulseaudio.socket && \ + sed -i 's/autospawn = .*$/autospawn = no/' /etc/pulse/client.conf && \ + sed -i 's/enable-shm = .*$/enable-shm = false/' /etc/pulse/client.conf && \ + usermod -aG pulse-access root + +# Expose ports for HTTP control as needed, for example: +EXPOSE 8001 8081 + +# Create and set up the configuration directory +VOLUME ["/configs"] + +# Set the working directory in the container +WORKDIR /app + +# Copy the rest of the directory contents into the container at /app +COPY ./app /app + +# Copy the pre-built opus libraries +COPY ./opus /app/opus + +# Run the node script +ENTRYPOINT ["uvicorn", "bot:app", "--host", "0.0.0.0", "--port", "8001", "--reload"] \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..96def1d --- /dev/null +++ b/Makefile @@ -0,0 +1,38 @@ +# Path to the Docker context directory +SDR_NODE_CONTEXT = $(shell pwd) + +# Path to the .env file +ENV_FILE = $(SDR_NODE_CONTEXT)/.env + +# Variables +IMAGE_NAME = client-bot +CONTAINER_NAME = client-bot + +# Target to build the Docker image for sdr-node +build: + docker build -t $(IMAGE_NAME) $(SDR_NODE_CONTEXT) + +# Target to run the sdr-node container +run: build + docker run --rm -it \ + --privileged \ + -v /dev:/dev \ + -v $(shell pwd)/configs:/configs \ + -v $(shell pwd)/app:/app \ + --name $(CONTAINER_NAME) \ + --network=host \ + $(IMAGE_NAME) + +# Stop the Docker container +stop: + docker stop $(CONTAINER_NAME) + +# Remove the Docker container +remove: + docker rm $(CONTAINER_NAME) + +# Clean up (stop and remove the container) +clean: stop remove + +# Rebuild the Docker image +rebuild: clean build \ No newline at end of file diff --git a/app/NoiseGatev2.py b/app/NoiseGatev2.py new file mode 100644 index 0000000..158937e --- /dev/null +++ b/app/NoiseGatev2.py @@ -0,0 +1,215 @@ +import audioop +import logging +import math +import time + +import pyaudio +import discord +import numpy + +voice_connection = None + +LOGGER = logging.getLogger("Discord_Radio_Bot.NoiseGateV2") + + +# noinspection PyUnresolvedReferences +class AudioStream: + def __init__(self, _channels: int = 2, _sample_rate: int = 48000, _frames_per_buffer: int = 1024, + _input_device_index: int = None, _output_device_index: int = None, _input: bool = True, + _output: bool = True, _init_on_startup: bool = True): + self.paInstance_kwargs = { + 'format': pyaudio.paInt16, + 'channels': _channels, + 'rate': _sample_rate, + 'input': _input, + 'output': _output, + 'frames_per_buffer': _frames_per_buffer + } + + if _input_device_index: + if _input: + self.paInstance_kwargs['input_device_index'] = _input_device_index + else: + LOGGER.warning(f"[AudioStream.__init__]:\tInput was not enabled." + f" Reinitialize with '_input=True'") + + if _output_device_index: + if _output: + self.paInstance_kwargs['output_device_index'] = _output_device_index + else: + LOGGER.warning(f"[AudioStream.__init__]:\tOutput was not enabled." + f" Reinitialize with '_output=True'") + + if _init_on_startup: + # Init PyAudio instance + LOGGER.info("Creating PyAudio instance") + self.paInstance = pyaudio.PyAudio() + + # Define and initialize stream object if we have been passed a device ID (pyaudio.open) + self.stream = None + + if _output_device_index or _input_device_index: + if _init_on_startup: + LOGGER.info("Init stream") + self.init_stream() + + def init_stream(self, _new_output_device_index: int = None, _new_input_device_index: int = None): + # Check what device was asked to be changed (or set) + if _new_input_device_index: + if self.paInstance_kwargs['input']: + self.paInstance_kwargs['input_device_index'] = _new_input_device_index + else: + LOGGER.warning(f"[AudioStream.init_stream]:\tInput was not enabled when initialized." + f" Reinitialize with '_input=True'") + + if _new_output_device_index: + if self.paInstance_kwargs['output']: + self.paInstance_kwargs['output_device_index'] = _new_output_device_index + else: + LOGGER.warning(f"[AudioStream.init_stream]:\tOutput was not enabled when initialized." + f" Reinitialize with '_output=True'") + + self.close_if_open() + + # Open the stream + self.stream = self.paInstance.open(**self.paInstance_kwargs) + + def close_if_open(self): + # Stop the stream if it is started + if self.stream: + if self.stream.is_active(): + self.stream.stop_stream() + self.stream.close() + LOGGER.debug(f"[ReopenStream.close_if_open]:\t Stream was open; It was closed.") + + def list_devices(self, _display_input_devices: bool = True, _display_output_devices: bool = True): + LOGGER.info('Getting a list of the devices connected') + info = self.paInstance.get_host_api_info_by_index(0) + numdevices = info.get('deviceCount') + + devices = { + 'Input': {}, + 'Output': {} + } + for i in range(0, numdevices): + if (self.paInstance.get_device_info_by_host_api_device_index(0, i).get('maxInputChannels')) > 0: + input_device = self.paInstance.get_device_info_by_host_api_device_index(0, i).get('name') + devices['Input'][i] = input_device + if _display_input_devices: + LOGGER.debug(f"Input Device id {i} - {input_device}") + + if (self.paInstance.get_device_info_by_host_api_device_index(0, i).get('maxOutputChannels')) > 0: + output_device = self.paInstance.get_device_info_by_host_api_device_index(0, i).get('name') + devices['Output'][i] = output_device + if _display_output_devices: + LOGGER.debug(f"Output Device id {i} - {output_device}") + + return devices + + async def stop(self): + await voice_connection.disconnect() + self.close_if_open() + self.stream.close() + self.paInstance.terminate() + + +# noinspection PyUnresolvedReferences +class NoiseGate(AudioStream): + def __init__(self, _voice_connection, _noise_gate_threshold: int, **kwargs): + super(NoiseGate, self).__init__(_init_on_startup=True, **kwargs) + global voice_connection + voice_connection = _voice_connection + self.THRESHOLD = _noise_gate_threshold + self.NGStream = NoiseGateStream(self) + self.Voice_Connection_Thread = None + + def run(self) -> None: + global voice_connection + # Start the audio stream + LOGGER.debug(f"Starting stream") + self.stream.start_stream() + # Start the stream to discord + self.core() + + def core(self, error=None): + if error: + LOGGER.warning(error) + + while not voice_connection.is_connected(): + time.sleep(.2) + + if not voice_connection.is_playing(): + LOGGER.debug(f"Playing stream to discord") + voice_connection.play(self.NGStream, after=self.core) + + async def close(self): + LOGGER.debug(f"Closing") + await voice_connection.disconnect() + if self.stream.is_active: + self.stream.stop_stream() + LOGGER.debug(f"Stopping stream") + + +# noinspection PyUnresolvedReferences +class NoiseGateStream(discord.AudioSource): + def __init__(self, _stream): + super(NoiseGateStream, self).__init__() + self.stream = _stream # The actual audio stream object + self.NG_fadeout = 240/20 # Fadeout value used to hold the noisegate after de-triggering + self.NG_fadeout_count = 0 # A count set when the noisegate is triggered and was de-triggered + self.process_set_count = 0 # Counts how many processes have been made + + def read(self): + try: + while voice_connection.is_connected(): + curr_buffer = bytearray(self.stream.stream.read(960)) + buffer_rms = audioop.rms(curr_buffer, 2) + if buffer_rms > 0: + buffer_decibel = 20 * math.log10(buffer_rms) + + if self.process_set_count % 10 == 0: + if buffer_decibel >= self.stream.THRESHOLD: + LOGGER.debug(f"[Noisegate Open] {buffer_decibel} db") + else: + LOGGER.debug(f"[Noisegate Closed] {buffer_decibel} db") + + if buffer_decibel >= self.stream.THRESHOLD: + self.NG_fadeout_count = self.NG_fadeout + self.process_set_count += 1 + if curr_buffer: + return bytes(curr_buffer) + + else: + if self.NG_fadeout_count > 0: + self.NG_fadeout_count -= 1 + LOGGER.debug(f"Frames in fadeout remaining: {self.NG_fadeout_count}") + self.process_set_count += 1 + if curr_buffer: + return bytes(curr_buffer) + + except OSError as e: + LOGGER.warning(e) + pass + + def audio_datalist_set_volume(self, datalist, volume): + """ Change value of list of audio chunks """ + sound_level = (volume / 100.) + + for i in range(len(datalist)): + chunk = numpy.fromstring(datalist[i], numpy.int16) + + chunk = chunk * sound_level + + datalist[i] = chunk.astype(numpy.int16) + + +if __name__ == '__main__': + + input_index = int(input("Input:\t")) + output_index = int(input("Output:\t")) + + ng = NoiseGate(_input_device_index=input_index, _output_device_index=output_index) + + ng.list_devices() + + ng.start() diff --git a/app/bot.py b/app/bot.py new file mode 100644 index 0000000..f355b73 --- /dev/null +++ b/app/bot.py @@ -0,0 +1,157 @@ +import asyncio +from typing import Optional, Dict + +from fastapi import FastAPI, HTTPException +from pydantic import BaseModel +import discord +from discord.ext import commands +from NoiseGatev2 import NoiseGate +import op25_controller +import pulse + +# Define FastAPI app +app = FastAPI() + +# Discord Bot Setup +intents = discord.Intents.default() +intents.voice_states = True +intents.guilds = True + +# Models for API requests +class BotConfig(BaseModel): + token: str # Discord Bot Token + +class VoiceChannelRequest(BaseModel): + guild_id: int + channel_id: int + +# Discord Bot Manager +class DiscordBotManager: + def __init__(self): + self.bot: Optional[commands.Bot] = None + self.bot_task: Optional[asyncio.Task] = None + self.voice_clients: Dict[int, discord.VoiceClient] = {} + self.token: Optional[str] = None + self.loop = asyncio.get_event_loop() + self.lock = asyncio.Lock() + + async def start_bot(self, token: str): + async with self.lock: + if self.bot and self.bot.is_closed(): + raise RuntimeError("Bot is already running.") + if self.bot_task and not self.bot_task.done(): + raise RuntimeError("Bot is already running.") + + self.token = token + self.bot = commands.Bot(command_prefix="!", intents=intents) + + @self.bot.event + async def on_ready(): + print(f'Logged in as {self.bot.user}') + + # Handle graceful shutdown when all voice connections are closed + @self.bot.event + async def on_voice_state_update(member, before, after): + # Check if all voice clients are disconnected + await asyncio.sleep(1) # Give time for the state to update + if not self.voice_clients: + await self.stop_bot() + + # Start the bot in the background + self.bot_task = self.loop.create_task(self.bot.start(token)) + + async def stop_bot(self): + async with self.lock: + if self.bot: + await self.bot.close() + self.bot = None + if self.bot_task: + await self.bot_task + self.bot_task = None + self.voice_clients.clear() + print("Bot has been stopped.") + + async def join_voice_channel(self, guild_id: int, channel_id: int, ng_threshold: int = 50, device_id: int = 4): + if not self.bot: + raise RuntimeError("Bot is not running.") + + guild = self.bot.get_guild(guild_id) + if not guild: + raise ValueError("Guild not found.") + + channel = guild.get_channel(channel_id) + if not isinstance(channel, discord.VoiceChannel): + raise ValueError("Channel is not a voice channel.") + + if guild_id in self.voice_clients: + raise RuntimeError("Already connected to this guild's voice channel.") + + voice_client = await channel.connect() + streamHandler = NoiseGate( + _input_device_index=device_id, + _voice_connection=voice_client, + _noise_gate_threshold=ng_threshold) + # Start the audio stream + streamHandler.run() + self.voice_clients[guild_id] = voice_client + print(f"Joined guild {guild_id} voice channel {channel_id}.") + + async def leave_voice_channel(self, guild_id: int): + if not self.bot: + raise RuntimeError("Bot is not running.") + + voice_client = self.voice_clients.get(guild_id) + if not voice_client: + raise RuntimeError("Not connected to the specified guild's voice channel.") + + await voice_client.disconnect() + del self.voice_clients[guild_id] + print(f"Left guild {guild_id} voice channel.") + +# Initialize Discord Bot Manager +bot_manager = DiscordBotManager() + +# API Endpoints +@app.post("/start_bot") +async def start_bot(config: BotConfig): + try: + await bot_manager.start_bot(config.token) + return {"status": "Bot started successfully."} + except Exception as e: + raise HTTPException(status_code=400, detail=str(e)) + +@app.post("/stop_bot") +async def stop_bot(): + try: + await bot_manager.stop_bot() + return {"status": "Bot stopped successfully."} + except Exception as e: + raise HTTPException(status_code=400, detail=str(e)) + +@app.post("/join_voice") +async def join_voice_channel(request: VoiceChannelRequest): + try: + await bot_manager.join_voice_channel(request.guild_id, request.channel_id) + return {"status": f"Joined guild {request.guild_id} voice channel {request.channel_id}."} + except Exception as e: + raise HTTPException(status_code=400, detail=str(e)) + +@app.post("/leave_voice") +async def leave_voice_channel(request: VoiceChannelRequest): + try: + await bot_manager.leave_voice_channel(request.guild_id) + return {"status": f"Left guild {request.guild_id} voice channel."} + except Exception as e: + raise HTTPException(status_code=400, detail=str(e)) + +@app.get("/status") +async def get_status(): + status = { + "bot_running": bot_manager.bot is not None and not bot_manager.bot.is_closed(), + "connected_guilds": list(bot_manager.voice_clients.keys()) + } + return status + + +app.include_router(op25_controller.router, prefix="/op25") +app.include_router(pulse.router, prefix="/pulse") \ No newline at end of file diff --git a/app/get_devices.py b/app/get_devices.py new file mode 100644 index 0000000..44964f5 --- /dev/null +++ b/app/get_devices.py @@ -0,0 +1,11 @@ +from NoiseGatev2 import AudioStream + +print('Getting a list of devices') +list_of_devices = AudioStream().list_devices() +print("----- INPUT DEVICES -----") +for inputDevice in list_of_devices['Input']: + print(f"{inputDevice}\t-\t{list_of_devices['Input'][inputDevice]}") + +print("----- OUTPUT DEVICES -----") +for outputDevice in list_of_devices['Output']: + print(f"{outputDevice}\t-\t{list_of_devices['Output'][outputDevice]}") \ No newline at end of file diff --git a/app/op25_controller.py b/app/op25_controller.py new file mode 100644 index 0000000..74f976f --- /dev/null +++ b/app/op25_controller.py @@ -0,0 +1,219 @@ +from fastapi import FastAPI, HTTPException, APIRouter +from pydantic import BaseModel +from enum import Enum +import subprocess +import os +import signal +import json +from typing import List, Optional, Union + +router = APIRouter() + +op25_process = None +OP25_PATH = "/op25/op25/gr-op25_repeater/apps/" +OP25_SCRIPT = "run_multi-rx_service.sh" + +@router.post("/start") +async def start_op25(): + global op25_process + if op25_process is None: + try: + op25_process = subprocess.Popen(os.path.join(OP25_PATH, OP25_SCRIPT), shell=True, preexec_fn=os.setsid, cwd=OP25_PATH) + print(op25_process) + return {"status": "OP25 started"} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + else: + return {"status": "OP25 already running"} + +@router.post("/stop") +async def stop_op25(): + global op25_process + if op25_process is not None: + try: + os.killpg(os.getpgid(op25_process.pid), signal.SIGTERM) + op25_process = None + return {"status": "OP25 stopped"} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + else: + return {"status": "OP25 is not running"} + +@router.get("/status") +async def get_status(): + return {"status": "running" if op25_process else "stopped"} + +class DecodeMode(str, Enum): + P25 = "P25" + DMR = "DMR" + ANALOG = "NBFM" + +class P25Config(BaseModel): + systemName: str + controlChannels: List[str] + tagsFile: str + whitelistFile: Optional[str] = None + +class NBFMConfig(BaseModel): + systemName: str + frequency: float + nbfmSquelch: Optional[float] = -70 + +class ConfigGenerator(BaseModel): + type: DecodeMode + config: Union[P25Config, NBFMConfig] + +class DemodType(str, Enum): + CQPSK = "cqpsk" + FSK4 = "fsk4" + +class FilterType(str, Enum): + RC = "rc" + WIDEPULSE = "widepulse" + +class ChannelConfig(BaseModel): + name: str + trunking_sysname: Optional[str] + enable_analog: str + demod_type: DemodType + filter_type: FilterType + device: Optional[str] = "sdr" + cqpsk_tracking: Optional[bool] = None + frequency: Optional[float] = None + nbfmSquelch: Optional[float] = None + destination: Optional[str] = "udp://127.0.0.1:23456" + tracking_threshold: Optional[int] = 120 + tracking_feedback: Optional[float] = 0.75 + excess_bw: Optional[float] = 0.2 + if_rate: Optional[int] = 24000 + plot: Optional[str] = "" + symbol_rate: Optional[int] = 4800 + blacklist: Optional[str] = "" + whitelist: Optional[str] = "" + +class DeviceConfig(BaseModel): + args: Optional[str] = "rtl" + gains: Optional[str] = "lna:39" + gain_mode: Optional[bool] = False + name: Optional[str] = "sdr" + offset: Optional[int] = 0 + ppm: Optional[float] = 0.0 + rate: Optional[int] = 1920000 + usable_bw_pct: Optional[float] = 0.85 + tunable: Optional[bool] = True + +class TrunkingChannelConfig(BaseModel): + sysname: str + control_channel_list: str + tagsFile: Optional[str] = None + whitelist: Optional[str] = None + nac: Optional[str] = "" + wacn: Optional[str] = "" + tdma_cc: Optional[bool] = False + crypt_behavior: Optional[int] = 2 + +class TrunkingConfig(BaseModel): + module: str + chans: List[TrunkingChannelConfig] + +class AudioInstanceConfig(BaseModel): + instance_name: Optional[str] = "audio0" + device_name: Optional[str] = "pulse" + udp_port: Optional[int] = 23456 + audio_gain: Optional[float] = 2.5 + number_channels: Optional[int] = 1 + +class AudioConfig(BaseModel): + module: Optional[str] = "sockaudio.py" + instances: Optional[List[AudioInstanceConfig]] = [AudioInstanceConfig()] + +class TerminalConfig(BaseModel): + module: Optional[str] = "terminal.py" + terminal_type: Optional[str] = "http:0.0.0.0:8081" + terminal_timeout: Optional[float] = 5.0 + curses_plot_interval: Optional[float] = 0.2 + http_plot_interval: Optional[float] = 1.0 + http_plot_directory: Optional[str] = "../www/images" + tuning_step_large: Optional[int] = 1200 + tuning_step_small: Optional[int] = 100 + +@router.post("/generate-config") +async def generate_config(generator: ConfigGenerator): + try: + if generator.type == DecodeMode.P25: + config_data = generator.config + channels = [ChannelConfig( + name=config_data.systemName, + trunking_sysname=config_data.systemName, + enable_analog="off", + demod_type="cqpsk", + cqpsk_tracking=True, + filter_type="rc" + )] + devices = [DeviceConfig()] + trunking = TrunkingConfig( + module="tk_p25.py", + chans=[TrunkingChannelConfig( + sysname=config_data.systemName, + control_channel_list=','.join(config_data.controlChannels), + tagsFile=config_data.tagsFile, + whitelist=config_data.whitelistFile + )] + ) + + audio = AudioConfig() + + terminal = TerminalConfig() + + config_dict = { + "channels": [channel.dict() for channel in channels], + "devices": [device.dict() for device in devices], + "trunking": trunking.dict(), + "audio": audio.dict(), + "terminal": terminal.dict() + } + + elif generator.type == DecodeMode.ANALOG: + config_data = generator.config + channels = [ChannelConfig( + channelName=config_data.systemName, + enableAnalog="on", + demodType="fsk4", + frequency=config_data.frequency, + filterType="widepulse", + nbfmSquelch=config_data.nbfmSquelch + )] + devices = [DeviceConfig(gain="LNA:32")] + + config_dict = { + "channels": [channel.dict() for channel in channels], + "devices": [device.dict() for device in devices] + } + + else: + raise HTTPException(status_code=400, detail="Invalid configuration type. Must be 'p25' or 'nbfm'.") + + with open('/configs/active.cfg.json', 'w') as f: + json.dump(del_none_in_dict(config_dict), f, indent=2) + + return {"message": f"Config exported to '/configs/active.cfg.json'"} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +def del_none_in_dict(d): + """ + Delete keys with the value ``None`` in a dictionary, recursively. + + This alters the input so you may wish to ``copy`` the dict first. + """ + for key, value in list(d.items()): + print(f"Key: '{key}'\nValue: '{value}'") + if value is None: + del d[key] + elif isinstance(value, dict): + del_none_in_dict(value) + elif isinstance(value, list): + for iterative_value in value: + del_none_in_dict(iterative_value) + return d # For convenience \ No newline at end of file diff --git a/app/pulse.py b/app/pulse.py new file mode 100644 index 0000000..76529a6 --- /dev/null +++ b/app/pulse.py @@ -0,0 +1,14 @@ +from fastapi import APIRouter +import subprocess +import os + +router = APIRouter() + +pulse_process = subprocess.Popen("pulseaudio --daemonize=no --system --realtime --log-target=journal", shell=True, preexec_fn=os.setsid) + +@router.get("/status") +async def get_status(): + return {"status": "running" if pulse_process else "stopped"} + + +# subprocess.Popen(os.path.join(OP25_PATH, OP25_SCRIPT), shell=True, preexec_fn=os.setsid, cwd=OP25_PATH) \ No newline at end of file diff --git a/opus/libopus_aarcch64.so b/opus/libopus_aarcch64.so new file mode 100644 index 0000000..8359d93 Binary files /dev/null and b/opus/libopus_aarcch64.so differ diff --git a/opus/libopus_amd64.dll b/opus/libopus_amd64.dll new file mode 100644 index 0000000..74a8e35 Binary files /dev/null and b/opus/libopus_amd64.dll differ diff --git a/opus/libopus_armv7l.so b/opus/libopus_armv7l.so new file mode 100644 index 0000000..7445645 Binary files /dev/null and b/opus/libopus_armv7l.so differ diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..5db2756 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,8 @@ +discord +PyNaCl +numpy==1.24.3 +uvicorn +fastapi +pyaudio +argparse +pyaudio \ No newline at end of file