Refactored to better split everything up

This commit is contained in:
2025-03-01 01:31:17 -05:00
parent f1de077b72
commit 59ee866ac9
14 changed files with 312 additions and 192 deletions

215
app/internal/NoiseGatev2.py Normal file
View File

@@ -0,0 +1,215 @@
import audioop
import math
import time
import pyaudio
import discord
import numpy
from internal.logger import create_logger
voice_connection = None
LOGGER = create_logger(__name__)
# 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()

139
app/internal/bot_manager.py Normal file
View File

@@ -0,0 +1,139 @@
import asyncio
import platform
import os
from discord import VoiceClient, VoiceChannel, opus, Activity, ActivityType, Intents
from discord.ext import commands
from typing import Optional, Dict
from internal.NoiseGatev2 import NoiseGate
from internal.logger import create_logger
LOGGER = create_logger(__name__)
# Configure discord intents
intents = Intents.default()
intents.voice_states = True
intents.guilds = True
class DiscordBotManager:
def __init__(self):
self.bot: Optional[commands.Bot] = None
self.bot_task: Optional[asyncio.Task] = None
self.voice_clients: Dict[int, 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 not 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():
LOGGER.info(f'Logged in as {self.bot.user}')
@self.bot.event
async def on_voice_state_update(member, before, after):
# Check if the bot was disconnected
if member == self.bot.user and after.channel is None:
guild_id = before.channel.guild.id
LOGGER.info(f"Bot was disconnected from channel in guild {guild_id}. Attempting to reconnect...")
try:
await leave_voice_channel(guild_id)
except Exception as e:
LOGGER.warning(f"Error leaving voice channel: '{e}'")
# Attempt to reconnect to the channel after a brief pause
await asyncio.sleep(2)
await self.join_voice_channel(guild_id, before.channel.id)
# Load Opus for the current CPU
await self.load_opus()
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()
LOGGER.info("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.")
if not opus.is_loaded():
raise RuntimeError("Opus is not loaded.")
channel = guild.get_channel(channel_id)
if not isinstance(channel, 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.")
try:
voice_client = await channel.connect(timeout=60.0, reconnect=True)
LOGGER.debug(f"Voice Connected.")
streamHandler = NoiseGate(
_input_device_index=device_id,
_voice_connection=voice_client,
_noise_gate_threshold=ng_threshold)
streamHandler.run()
LOGGER.debug(f"Stream is running.")
self.voice_clients[guild_id] = voice_client
LOGGER.info(f"Joined guild {guild_id} voice channel {channel_id} and stream is running.")
except Exception as e:
LOGGER.error(f"Failed to connect to voice channel: {e}")
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]
LOGGER.info(f"Left guild {guild_id} voice channel.")
async def load_opus(self):
""" Load the proper OPUS library for the device being used """
processor = platform.machine()
script_dir = os.path.dirname(os.path.abspath(__file__))
LOGGER.debug("Processor: ", processor)
if os.name == 'nt':
if processor == "AMD64":
opus.load_opus(os.path.join(script_dir, './opus/libopus_amd64.dll'))
LOGGER.info(f"Loaded OPUS library for AMD64")
return "AMD64"
else:
if processor == "aarch64":
opus.load_opus(os.path.join(script_dir, './opus/libopus_aarcch64.so'))
LOGGER.info(f"Loaded OPUS library for aarch64")
return "aarch64"
elif processor == "armv7l":
opus.load_opus(os.path.join(script_dir, './opus/libopus_armv7l.so'))
LOGGER.info(f"Loaded OPUS library for armv7l")
return "armv7l"
async def set_presence(self, presence: str):
""" Set the presense (activity) of the bot """
try:
await self.bot.change_presence(activity=Activity(type=ActivityType.listening, name=presence))
except Exception as pe:
LOGGER.error(f"Unable to set presence: '{pe}'")

View File

@@ -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]}")

55
app/internal/logger.py Normal file
View File

@@ -0,0 +1,55 @@
import logging
from logging.handlers import RotatingFileHandler
def create_logger(name, level=logging.DEBUG, max_bytes=10485760, backup_count=2):
"""
Creates a logger with a console and rotating file handlers for both debug and info log levels.
Args:
name (str): The name for the logger.
level (int): The logging level for the logger. Defaults to logging.DEBUG.
max_bytes (int): Maximum size of the log file in bytes before it gets rotated. Defaults to 10 MB.
backup_count (int): Number of backup files to keep. Defaults to 2.
Returns:
logging.Logger: Configured logger.
"""
# Set the log file paths
debug_log_file = "./client.debug.log"
info_log_file = "./client.log"
# Create a logger
logger = logging.getLogger(name)
logger.setLevel(level)
# Check if the logger already has handlers to avoid duplicate logs
if not logger.hasHandlers():
# Create console handler
console_handler = logging.StreamHandler()
console_handler.setLevel(level)
# Create rotating file handler for debug level
debug_file_handler = RotatingFileHandler(debug_log_file, maxBytes=max_bytes, backupCount=backup_count)
debug_file_handler.setLevel(logging.DEBUG)
# Create rotating file handler for info level
info_file_handler = RotatingFileHandler(info_log_file, maxBytes=max_bytes, backupCount=backup_count)
info_file_handler.setLevel(logging.INFO)
# Create formatter and add it to the handlers
formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
console_handler.setFormatter(formatter)
debug_file_handler.setFormatter(formatter)
info_file_handler.setFormatter(formatter)
# Add the handlers to the logger
logger.addHandler(console_handler)
logger.addHandler(debug_file_handler)
logger.addHandler(info_file_handler)
return logger
# Example usage:
# logger = create_logger('my_logger')
# logger.debug('This is a debug message')
# logger.info('This is an info message')

Binary file not shown.

Binary file not shown.

Binary file not shown.