diff --git a/BotResources.py b/BotResources.py index bcfab85..17939b4 100644 --- a/BotResources.py +++ b/BotResources.py @@ -1,6 +1,6 @@ -import sound import configparser from os.path import exists +from NoiseGatev2 import AudioStream PDB_ACCEPTABLE_HANDLERS = {'gqrx': { 'Modes': ['wfm', 'fm'] @@ -10,6 +10,8 @@ PDB_ACCEPTABLE_HANDLERS = {'gqrx': { }} PDB_KNOWN_BOT_IDS = {756327271597473863: "Greada", 915064996994633729: "Jorn", 943742040255115304: "Brent"} +DEFAULT_NOISEGATE_THRESHOLD = 50 + def check_if_config_exists(): if exists('./config.ini'): diff --git a/NoiseGatev2.py b/NoiseGatev2.py new file mode 100644 index 0000000..c0f4bfd --- /dev/null +++ b/NoiseGatev2.py @@ -0,0 +1,185 @@ +import audioop +import math +import pyaudio +import discord +import numpy + +voice_connection = None + + +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: + print(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: + print(f"[AudioStream.__init__]:\tOutput was not enabled." + f" Reinitialize with '_output=True'") + + if _init_on_startup: + # Init PyAudio instance + print("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: + print("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: + print(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: + print(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() + print(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): + 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: + print("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: + print("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() + + +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) + + def run(self) -> None: + global voice_connection + # Start the audio stream + self.stream.start_stream() + voice_connection.play(self.NGStream) + + async def close(self): + await voice_connection.disconnect() + if self.stream.is_active: + self.stream.stop_stream() + +class NoiseGateStream(discord.AudioSource): + def __init__(self, _stream): + super(NoiseGateStream, self).__init__() + self.stream = _stream # The actual audio stream object + self.NG_fadeout = 480/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 in order to limit the prints + + 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: + print(f"{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 + print(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: + 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/bot.py b/bot.py index 6d9576b..e30a7fd 100644 --- a/bot.py +++ b/bot.py @@ -3,8 +3,8 @@ import os import platform import discord import BotResources -import sound import configparser +import NoiseGatev2 from discord.ext import commands @@ -33,13 +33,16 @@ class Bot(commands.Bot): self.Bot_Connected = False # Init the audio devices list - self.Devices_List = sound.query_devices().items() + #self.Devices_List = sound.query_devices().items() + self.Devices_List = NoiseGatev2.AudioStream().list_devices(_display_input_devices=False, + _display_output_devices=False) # Init radio parameters self.profile_name = None self.freq = "104700000" self.mode = "wfm" self.squelch = 0 + self.noisegate_sensitivity = BotResources.DEFAULT_NOISEGATE_THRESHOLD # Init SDR Variables self.system_os_type = None @@ -79,6 +82,7 @@ class Bot(commands.Bot): brief="Joins the voice channel that the caller is in") async def join(ctx, *, member: discord.Member = None): member = member or ctx.author.display_name + print(f"Join requested by {member}") if not self.Bot_Connected: # Wait for the bot to be ready to connect @@ -89,47 +93,53 @@ class Bot(commands.Bot): if discord.opus.is_loaded(): channel = ctx.author.voice.channel + print("Sending hello") await ctx.send(f"Ok {str(member).capitalize()}, I'm joining {channel}") # Join the voice channel with the audio stream + print('Joining') voice_connection = await channel.connect() # Create an audio stream from selected device - self.streamHandler = sound.PCMStream(self.DEVICE_ID) + print("Starting noisegate/stream handler") + self.streamHandler = NoiseGatev2.NoiseGate(_input_device_index=self.DEVICE_ID, + _voice_connection=voice_connection, + _noise_gate_threshold=self.noisegate_sensitivity) # Start the audio stream - await self.streamHandler.play() - - # Play the stream - voice_connection.play(discord.PCMAudio(self.streamHandler)) + self.streamHandler.run() # Start the SDR and begin playing to the audio stream + print("Starting SDR") self.start_sdr() # Change the activity to the channel and band-type being used + print("Changing presence") await self.set_activity() # 'Lock' the bot from connecting + print("Locking the bot") self.Bot_Connected = True else: # Return that the opus library would not load await ctx.send("Opus won't load") + print("OPUS didn't load properly") else: await ctx.send(f"{str(member).capitalize()}, I'm already connected") + print("Bot is already in a channel") @self.command(help="Use this command to have the bot leave your channel", brief="Leaves the current voice channel") async def leave(ctx, member: discord.Member = None): member = member or ctx.author.display_name - if self.Bot_Connected: - print("Cleaning up") - # Stop the sound handlers - await self.streamHandler.pause() + print(f"Leave requested by {member}") - print("Disconnecting") + if self.Bot_Connected: + # Stop the sound handlers # Disconnect the client from the voice channel - await ctx.voice_client.disconnect() + print("Disconnecting") + await self.streamHandler.close() print("Changing presence") # Change the presence to away and '@ me' @@ -148,6 +158,18 @@ class Bot(commands.Bot): else: await ctx.send(f"{str(member).capitalize()}, I'm not in a channel") + # Add command to change the NoiseGate threshold + @self.command(help="Use this command to change the threshold of the noisegate", + brief="Change noisegate sensitivity") + async def chngth(ctx, _threshold: int, member: discord.Member = None): + member = member or ctx.author.display_name + print(f"Change of NoiseGate threshold requested by {member}") + await ctx.send(f"Ok {str(member).capitalize()}, I'm changing the threshold from " + f"{self.streamHandler.THRESHOLD} to {_threshold}") + self.streamHandler.THRESHOLD = _threshold + if self.sdr_started: + await self.set_activity() + # Add commands for GQRX and OP25 if self.Handler in BotResources.PDB_ACCEPTABLE_HANDLERS.keys(): # Command to display the current config @@ -318,26 +340,29 @@ class Bot(commands.Bot): # Load the proper OPUS library for the device being used def load_opus(self): # Check the system type and load the correct library - # Linux ARM AARCH64 running 32bit OS if self.system_os_type == 'Linux_ARMv7l': + print(f"Loaded OPUS library for {self.system_os_type}") discord.opus.load_opus('./opus/libopus_armv7l.so') # Linux ARM AARCH64 running 64bit OS if self.system_os_type == 'Linux_AARCH64': + print(f"Loaded OPUS library for {self.system_os_type}") discord.opus.load_opus('./opus/libopus_aarcch64.so') # Windows 64bit if self.system_os_type == 'Windows_x64': + print(f"Loaded OPUS library for {self.system_os_type}") discord.opus.load_opus('./opus/libopus_amd64.dll') # Check to make sure the selected device is still available and has not changed its index def check_device(self, _override): # Check to see if an override has been passed if not _override: - for device, index in self.Devices_List: + print(f"Device list {self.Devices_List}") + for device, index in self.Devices_List['Input']: if int(index) == self.DEVICE_ID and str(device) == self.DEVICE_NAME: return True - for device, index in self.Devices_List: + for device, index in self.Devices_List['Input']: if str(device) == self.DEVICE_NAME: self.DEVICE_ID = int(index) return True @@ -378,11 +403,14 @@ class Bot(commands.Bot): if os.name == 'nt': if processor == "AMD64": self.system_os_type = 'Windows_x64' + print(f"OS/Arch is {self.system_os_type}") else: if processor == "aarch64": self.system_os_type = 'Linux_AARCH64' + print(f"OS/Arch is {self.system_os_type}") elif processor == "armv7l": self.system_os_type = 'Linux_ARMv7l' + print(f"OS/Arch is {self.system_os_type}") # Check to see if there is only one frequency def start_sdr(self): @@ -437,13 +465,14 @@ class Bot(commands.Bot): await self.change_presence(activity=discord.Game(name=f"@ me"), status=discord.Status.idle) # Save the current radio settings as a profile - async def save_radio_config(self, profile_name: str): + async def save_radio_config(self, _profile_name: str): + print(f"Saving profile {_profile_name}") config = configparser.SafeConfigParser() if os.path.exists('./profiles.ini'): config.read('./profiles.ini') - profile_name = str(profile_name).upper() + profile_name = str(_profile_name).upper() if not config.has_section(str(profile_name)): config.add_section(str(profile_name)) @@ -451,6 +480,7 @@ class Bot(commands.Bot): config[str(profile_name)]['Frequency'] = self.freq config[str(profile_name)]['Mode'] = self.mode config[str(profile_name)]['Squelch'] = str(self.squelch) + config[str(profile_name)]['Noisegate_Sensitivity'] = str(self.noisegate_sensitivity) with open('./profiles.ini', 'w+') as config_file: config.write(config_file) @@ -463,6 +493,7 @@ class Bot(commands.Bot): # Load a saved profile into the current settings async def load_radio_config(self, profile_name): + print(f"Loading profile {profile_name}") config = configparser.ConfigParser() if os.path.exists('./profiles.ini'): config.read('./profiles.ini') @@ -471,6 +502,13 @@ class Bot(commands.Bot): self.freq = config[self.profile_name]['Frequency'] self.mode = config[self.profile_name]['Mode'] self.squelch = float(config[self.profile_name]['Squelch']) + try: + self.noisegate_sensitivity = int(config[self.profile_name]['Noisegate_Sensitivity']) + except KeyError: + print(f"Config does not contain a 'noisegate sensitivity' value, " + f"creating one now with the default value: {BotResources.DEFAULT_NOISEGATE_THRESHOLD}") + self.noisegate_sensitivity = BotResources.DEFAULT_NOISEGATE_THRESHOLD + await self.save_radio_config(self.profile_name) if self.sdr_started: self.start_sdr() @@ -486,10 +524,11 @@ class Bot(commands.Bot): message_body = "" if self.profile_name: message_body += f"Profile Name: {str(self.profile_name).upper()}\n" - message_body += f"Frequency: {self.freq}\n" \ - f"Mode: {str(self.mode).upper()}\n" + message_body += f"\tMode:\t\t\t\t\t{self.mode}\n" \ + f"\tFrequency:\t\t\t{self.freq}\n" \ + f"\tNoisegate Sensitivity:\t{self.noisegate_sensitivity}" if self.squelch: - message_body += f"Squelch: {self.squelch}" + message_body += f"\tSquelch:\t\t\t\t{self.squelch}" return message_body @@ -499,9 +538,14 @@ class Bot(commands.Bot): if os.path.exists('./profiles.ini'): config.read('./profiles.ini') for section in config.sections(): - message_body += f"\n{section}:\n" \ - f"\tMode:\t\t\t\t{config[section]['Mode']}\n" \ - f"\tFrequency:\t\t{config[section]['Frequency']}\n" \ - f"\tSquelch:\t\t\t{config[section]['Squelch']}\n" + message_body += f"\nProfile Name: {section}:\n" \ + f"\tMode:\t\t\t\t\t{config[section]['Mode']}\n" \ + f"\tFrequency:\t\t\t{config[section]['Frequency']}\n" + try: + message_body += f"\tNoisegate Sensitivity:\t{config[section]['Noisegate_Sensitivity']}\n" + except KeyError: + print(f"Config does not contain a 'noisegate sensitivity' value. Please load the profile") + + message_body += f"\tSquelch:\t\t\t\t{config[section]['Squelch']}\n" return message_body diff --git a/requirements.txt b/requirements.txt index 68e54f9..45d8f2e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,6 @@ discord~=1.7.3 -sounddevice~=0.4.3 -pynacl~=1.5.0 +numpy==1.22.3 +scipy==1.8.0 +matplotlib~=3.5.1 +pyrtlsdr~=0.2.92 +PyAudio~=0.2.11 diff --git a/sound.py b/sound.py deleted file mode 100644 index c33d97e..0000000 --- a/sound.py +++ /dev/null @@ -1,76 +0,0 @@ -import time - -import sounddevice -import sounddevice as sd -from pprint import pformat - -DEFAULT = 0 -sd.default.channels = 2 -sd.default.dtype = "int16" -sd.default.latency = "low" -sd.default.samplerate = 48000 - -class PCMStream: - globals() - - def __init__(self, _device_id): - self.stream = sd.RawInputStream(device=_device_id) - - def change_device(self, _device_id): - self.clean_up() - - self.stream = sd.RawInputStream(device=_device_id) - - # Stops and destroys the current stream - def clean_up(self): - if not self.stream.closed: - self.stream.stop(ignore_errors=True) - self.stream.close(ignore_errors=True) - - # Stops the current running stream but does not destroy it - async def pause(self): - if self.stream.active: - self.stream.stop(ignore_errors=True) - - # Plays the stream - async def play(self): - if not self.stream.active: - self.stream.start() - - # call back read function for the stream - def read(self, num_bytes): - if self.stream.active: - # frame is 4 bytes - frames = int(num_bytes / 4) - data = self.stream.read(frames)[0] - - # convert to pcm format - return bytes(data) - - -class DeviceNotFoundError(Exception): - def __init__(self): - self.devices = sd.query_devices() - self.host_apis = sd.query_hostapis() - super().__init__("No Devices Found") - - def __str__(self): - return ( - f"Devices \n" - f"{self.devices} \n " - f"Host APIs \n" - f"{pformat(self.host_apis)}" - ) - - -def query_devices(): - options = { - device.get("name"): index - for index, device in enumerate(sd.query_devices()) - if (device.get("max_input_channels") > 0 and device.get("hostapi") == DEFAULT) - } - - if not options: - raise DeviceNotFoundError() - - return options \ No newline at end of file