25 Commits

Author SHA1 Message Date
Logan Cusano
abb2d2f042 fix bot manager after revert 2025-07-14 22:09:21 -04:00
Logan Cusano
cd2ea546b8 revert noisegate 2025-07-14 22:06:36 -04:00
Logan Cusano
e3566fb367 add more debug 2025-07-14 21:58:03 -04:00
Logan Cusano
0a0d8d3af9 fix typo 2025-07-14 21:52:08 -04:00
Logan Cusano
bbd866a8a6 add debug 2025-07-14 21:49:53 -04:00
Logan Cusano
4f93c99a52 Fix opus error import 2025-07-14 21:41:31 -04:00
Logan Cusano
9040462171 redo noisegate to fix voice presense 2025-07-14 21:34:08 -04:00
Logan Cusano
f893209f36 fix voice activity 2025-07-14 21:27:18 -04:00
Logan Cusano
09e0541bec fix join loop 2025-07-14 21:20:58 -04:00
Logan Cusano
abd78c83d2 stream blank when no sound 2025-07-14 21:17:09 -04:00
Logan Cusano
a634ea2260 fix depend in bot router 2025-07-14 21:05:45 -04:00
Logan Cusano
e7ff28da6e Update bot manager and noisegate functions to try and improve the logic 2025-07-14 21:04:14 -04:00
Logan Cusano
b5191ef4d0 working on #7 2025-07-14 20:37:12 -04:00
Logan Cusano
042495cde2 Remove code to rejoin as it was not in the code originally #7
Some checks failed
release-tag / release-image (push) Failing after 7m41s
2025-07-13 01:17:23 -04:00
Logan Cusano
b65bea7856 create stable and nightly builds
All checks were successful
Lint / lint (push) Successful in 19s
release-tag / release-image (push) Successful in 1h6m26s
2025-07-13 01:03:03 -04:00
Logan Cusano
bd8deeb44e Fix typo in the presence endpoint
All checks were successful
release-tag / release-image (push) Successful in 1h15m13s
Lint / lint (push) Successful in 11s
2025-07-06 19:34:27 -04:00
7f455f427e Merge pull request 'implement-bot-presence' (#6) from implement-bot-presence into master
All checks were successful
release-tag / release-image (push) Successful in 1h14m4s
Lint / lint (push) Successful in 10s
Reviewed-on: #6
2025-06-29 15:56:03 -04:00
Logan Cusano
ddfa9fc2a3 Linting
All checks were successful
Lint / lint (pull_request) Successful in 6s
2025-06-29 15:53:15 -04:00
Logan Cusano
fb9f8a680f Implement presence change
Some checks failed
Lint / lint (pull_request) Failing after 7s
2025-06-29 15:24:19 -04:00
Logan Cusano
a26dd619b8 Update set presence in bot manager 2025-06-29 15:22:20 -04:00
Logan Cusano
133f29635e Update gitignore for venv 2025-06-29 15:20:44 -04:00
Logan Cusano
cbc2a3fc86 Return the active token in get status
Some checks failed
release-tag / release-image (push) Successful in 1h14m8s
Lint / lint (push) Failing after 10s
2025-06-29 02:44:10 -04:00
Logan Cusano
b3a5dbb626 Revert "Update docker to use supervisor"
Some checks failed
release-tag / release-image (push) Successful in 1h13m56s
Lint / lint (push) Failing after 9s
This reverts commit 3086da0e2b.
2025-06-23 01:11:50 -04:00
Logan Cusano
44684ed020 Revert "Add custom liquid and service files and updated docker"
This reverts commit 021f27d62e.
2025-06-23 01:11:18 -04:00
Logan Cusano
5ff1d6273f Revert "Update docker to include liquidsoap"
This reverts commit d3e7e780f3.
2025-06-23 01:09:39 -04:00
14 changed files with 479 additions and 550 deletions

View File

@@ -0,0 +1,57 @@
name: release-tag
on:
push:
branches:
- dev
jobs:
release-image:
runs-on: ubuntu-latest
env:
DOCKER_LATEST: stable
CONTAINER_NAME: drb-client-discord-bot
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker BuildX
uses: docker/setup-buildx-action@v3
with: # replace it with your local IP
config-inline: |
[registry."git.vpn.cusano.net"]
http = false
insecure = false
- name: Login to DockerHub
uses: docker/login-action@v3
with:
registry: git.vpn.cusano.net # replace it with your local IP
username: ${{ secrets.GIT_REPO_USERNAME }}
password: ${{ secrets.GIT_REPO_PASSWORD }}
- name: Get Meta
id: meta
run: |
echo REPO_NAME=$(echo ${GITHUB_REPOSITORY} | awk -F"/" '{print $2}') >> $GITHUB_OUTPUT
echo REPO_VERSION=$(git describe --tags --always | sed 's/^v//') >> $GITHUB_OUTPUT
- name: Validate build configuration
uses: docker/build-push-action@v6
with:
call: check
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
platforms: |
linux/arm64
push: true
tags: | # replace it with your local IP and tags
git.vpn.cusano.net/${{ vars.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}/${{ env.CONTAINER_NAME }}:${{ steps.meta.outputs.REPO_VERSION }}
git.vpn.cusano.net/${{ vars.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}/${{ env.CONTAINER_NAME }}:${{ env.DOCKER_LATEST }}

View File

@@ -9,7 +9,7 @@ jobs:
release-image: release-image:
runs-on: ubuntu-latest runs-on: ubuntu-latest
env: env:
DOCKER_LATEST: nightly DOCKER_LATEST: stable
CONTAINER_NAME: drb-client-discord-bot CONTAINER_NAME: drb-client-discord-bot
steps: steps:
- name: Checkout - name: Checkout

3
.gitignore vendored
View File

@@ -2,4 +2,5 @@ __pycache__*
bot-poc.py bot-poc.py
configs* configs*
.env .env
*.log* *.log*
.venv

View File

@@ -26,9 +26,7 @@ RUN apt-get update && \
libportaudio2 \ libportaudio2 \
libpulse-dev \ libpulse-dev \
apulse \ apulse \
ffmpeg \ ffmpeg
liquidsoap \
supervisor
# Clone the boatbod op25 repository # Clone the boatbod op25 repository
RUN git clone -b gr310 https://github.com/boatbod/op25 /op25 RUN git clone -b gr310 https://github.com/boatbod/op25 /op25
@@ -39,9 +37,6 @@ WORKDIR /op25
# Run the install script to set up op25 # Run the install script to set up op25
RUN ./install.sh -f RUN ./install.sh -f
# Update the liquid file
COPY op25.liq /op25/op25.liq
# Install Python dependencies # Install Python dependencies
COPY requirements.txt /tmp/requirements.txt COPY requirements.txt /tmp/requirements.txt
RUN pip3 install --no-cache-dir -r /tmp/requirements.txt RUN pip3 install --no-cache-dir -r /tmp/requirements.txt
@@ -71,8 +66,5 @@ COPY ./app/internal/opus /app/internal/opus
# Copy the rest of the directory contents into the container at /app # Copy the rest of the directory contents into the container at /app
COPY ./app /app COPY ./app /app
# Add Supervisord configuration # Run the node script
COPY supervisord.conf /etc/supervisor/conf.d/supervisord.conf ENTRYPOINT ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8001", "--reload"]
# Modify the ENTRYPOINT to run Supervisord
ENTRYPOINT ["/usr/bin/supervisord", "-n", "-c", "/etc/supervisor/conf.d/supervisord.conf"]

View File

@@ -1,215 +1,96 @@
import audioop import audioop
import math import math
import time
import pyaudio import pyaudio
import discord import asyncio
import numpy
from internal.logger import create_logger from internal.logger import create_logger
from discord import AudioSource
voice_connection = None
LOGGER = create_logger(__name__) LOGGER = create_logger(__name__)
# Constants for audio processing
# noinspection PyUnresolvedReferences SAMPLES_PER_FRAME = 960
class AudioStream: CHANNELS = 2
def __init__(self, _channels: int = 2, _sample_rate: int = 48000, _frames_per_buffer: int = 1024, SAMPLE_RATE = 48000
_input_device_index: int = None, _output_device_index: int = None, _input: bool = True, FRAME_SIZE = SAMPLES_PER_FRAME * CHANNELS * 2 # 16-bit PCM
_output: bool = True, _init_on_startup: bool = True): SILENT_FRAME = b'\x00' * FRAME_SIZE
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("[AudioStream.__init__]:\tInput was not enabled."
" Reinitialize with '_input=True'")
if _output_device_index:
if _output:
self.paInstance_kwargs['output_device_index'] = _output_device_index
else:
LOGGER.warning("[AudioStream.__init__]:\tOutput was not enabled."
" 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("[AudioStream.init_stream]:\tInput was not enabled when initialized."
" 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("[AudioStream.init_stream]:\tOutput was not enabled when initialized."
" 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("[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 NoiseGateSource(AudioSource):
class NoiseGate(AudioStream): def __init__(self, audio_stream, threshold: int):
def __init__(self, _voice_connection, _noise_gate_threshold: int, **kwargs): self.audio_stream = audio_stream
super(NoiseGate, self).__init__(_init_on_startup=True, **kwargs) self.threshold = threshold
global voice_connection self.ng_fadeout_count = 0
voice_connection = _voice_connection self.NG_FADEOUT_FRAMES = 12 # 240ms fadeout time
self.THRESHOLD = _noise_gate_threshold
self.NGStream = NoiseGateStream(self)
self.Voice_Connection_Thread = None
def run(self) -> None: def read(self) -> bytes:
global voice_connection """
# Start the audio stream Reads data from the audio stream, applies the noise gate,
LOGGER.debug("Starting stream") and returns a 20ms audio frame.
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("Playing stream to discord")
voice_connection.play(self.NGStream, after=self.core)
async def close(self):
LOGGER.debug("Closing")
await voice_connection.disconnect()
if self.stream.is_active:
self.stream.stop_stream()
LOGGER.debug("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: try:
while voice_connection.is_connected(): # Read a frame's worth of data from the input stream.
curr_buffer = bytearray(self.stream.stream.read(960)) pcm_data = self.audio_stream.read(SAMPLES_PER_FRAME, exception_on_overflow=False)
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: # Ensure we have a full frame of data.
if buffer_decibel >= self.stream.THRESHOLD: if len(pcm_data) != FRAME_SIZE:
LOGGER.debug(f"[Noisegate Open] {buffer_decibel} db") return
else:
LOGGER.debug(f"[Noisegate Closed] {buffer_decibel} db")
if buffer_decibel >= self.stream.THRESHOLD: # Calculate volume to check against the threshold.
self.NG_fadeout_count = self.NG_fadeout rms = audioop.rms(pcm_data, 2)
self.process_set_count += 1 if rms == 0:
if curr_buffer: # If there's no volume, check if we're in the fadeout period.
return bytes(curr_buffer) if self.ng_fadeout_count > 0:
self.ng_fadeout_count -= 1
return pcm_data # Return the (silent) data to complete the fade
return
else: db = 20 * math.log10(rms)
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: # If volume is above the threshold, send the audio and reset fadeout.
LOGGER.warning(e) if db >= self.threshold:
pass self.ng_fadeout_count = self.NG_FADEOUT_FRAMES
return pcm_data
def audio_datalist_set_volume(self, datalist, volume): # If below threshold but still in the fadeout period, send the audio.
""" Change value of list of audio chunks """ if self.ng_fadeout_count > 0:
sound_level = (volume / 100.) self.ng_fadeout_count -= 1
return pcm_data
for i in range(len(datalist)): # Otherwise, the gate is closed.
chunk = numpy.fromstring(datalist[i], numpy.int16) return
chunk = chunk * sound_level except Exception as e:
LOGGER.error(f"Error in NoiseGateSource.read: {e}", exc_info=True)
return SILENT_FRAME
datalist[i] = chunk.astype(numpy.int16) def cleanup(self) -> None:
"""Called when the player stops."""
if self.audio_stream:
self.audio_stream.stop_stream()
self.audio_stream.close()
LOGGER.info("Audio stream cleaned up.")
class AudioStreamManager:
"""Manages the PyAudio instance and input stream."""
def __init__(self, input_device_index: int):
self.pa = pyaudio.PyAudio()
self.stream = self.pa.open(
format=pyaudio.paInt16,
channels=CHANNELS,
rate=SAMPLE_RATE,
input=True,
frames_per_buffer=SAMPLES_PER_FRAME,
input_device_index=input_device_index
)
self.stream.start_stream()
LOGGER.info(f"Audio stream started on device {input_device_index}")
if __name__ == '__main__': def get_stream(self):
return self.stream
input_index = int(input("Input:\t")) def terminate(self):
output_index = int(input("Output:\t")) if self.stream and self.stream.is_active():
self.stream.stop_stream()
ng = NoiseGate(_input_device_index=input_index, _output_device_index=output_index) self.stream.close()
self.pa.terminate()
ng.list_devices() LOGGER.info("PyAudio instance terminated.")
ng.start()

View File

@@ -4,7 +4,7 @@ import os
from discord import VoiceClient, VoiceChannel, opus, Activity, ActivityType, Intents from discord import VoiceClient, VoiceChannel, opus, Activity, ActivityType, Intents
from discord.ext import commands from discord.ext import commands
from typing import Optional, Dict from typing import Optional, Dict
from internal.NoiseGatev2 import NoiseGate from internal.NoiseGatev2 import AudioStreamManager, NoiseGateSource
from internal.logger import create_logger from internal.logger import create_logger
LOGGER = create_logger(__name__) LOGGER = create_logger(__name__)
@@ -18,7 +18,7 @@ class DiscordBotManager:
def __init__(self): def __init__(self):
self.bot: Optional[commands.Bot] = None self.bot: Optional[commands.Bot] = None
self.bot_task: Optional[asyncio.Task] = None self.bot_task: Optional[asyncio.Task] = None
self.voice_clients: Dict[int, VoiceClient] = {} self.voice_connections: Dict[int, Dict] = {}
self.token: Optional[str] = None self.token: Optional[str] = None
self.loop = asyncio.get_event_loop() self.loop = asyncio.get_event_loop()
self.lock = asyncio.Lock() self.lock = asyncio.Lock()
@@ -38,136 +38,146 @@ class DiscordBotManager:
@self.bot.event @self.bot.event
async def on_ready(): async def on_ready():
LOGGER.info(f'Logged in as {self.bot.user}') LOGGER.info(f'Logged in as {self.bot.user}')
# Set the event when on_ready is called
self._ready_event.set() self._ready_event.set()
@self.bot.event @self.bot.event
async def on_voice_state_update(member, before, after): async def on_voice_state_update(member, before, after):
# Check if the bot was disconnected if member != self.bot.user:
if member == self.bot.user and after.channel is None: return
guild_id = before.channel.guild.id
if not self.voice_clients.get(guild_id):
LOGGER.info("Bot has left channel, reconnection ignored.")
return
LOGGER.info(f"Bot was disconnected from channel in guild {guild_id}. Attempting to reconnect...")
try:
await self.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)
if member == self.bot.user and before.channel is None and after.channel is not None: if before.channel is None and after.channel is not None:
print(f"{member.name} joined voice channel {after.channel.name}") LOGGER.info(f"{member.name} joined voice channel {after.channel.name}")
self._voice_ready_event.set() self._voice_ready_event.set()
elif before.channel is not None and after.channel is not None and before.channel != after.channel:
LOGGER.info(f"{member.name} was moved to voice channel {after.channel.name}")
if not self._voice_ready_event.is_set():
self._voice_ready_event.set()
elif before.channel is not None and after.channel is None:
LOGGER.warning(f"{member.name} left voice channel {before.channel.name}")
guild_id = before.channel.guild.id
if guild_id in self.voice_connections:
LOGGER.warning(f"Bot was disconnected from {guild_id} unexpectedly. Cleaning up...")
await self.leave_voice_channel(guild_id)
self._voice_ready_event.clear()
@self.bot.event
async def on_disconnect():
LOGGER.warning("Bot has been disconnected from Discord.")
# Load Opus for the current CPU
await self.load_opus() await self.load_opus()
# Create the task to run the bot in the background
self.bot_task = self.loop.create_task(self.bot.start(token)) self.bot_task = self.loop.create_task(self.bot.start(token))
# Wait for the on_ready event to be set by the bot task
LOGGER.info("Waiting for bot to become ready...") LOGGER.info("Waiting for bot to become ready...")
try: try:
await asyncio.wait_for(self._ready_event.wait(), timeout=60.0) await asyncio.wait_for(self._ready_event.wait(), timeout=60.0)
LOGGER.info("Bot is ready, start_bot returning.") LOGGER.info("Bot is ready.")
return
except asyncio.TimeoutError: except asyncio.TimeoutError:
LOGGER.error("Timeout waiting for bot to become ready. Bot might have failed to start.") LOGGER.error("Timeout waiting for bot to become ready.")
if self.bot_task and not self.bot_task.done(): if self.bot_task and not self.bot_task.done():
self.bot_task.cancel() self.bot_task.cancel()
raise RuntimeError("Bot failed to become ready within timeout.") raise RuntimeError("Bot failed to become ready within timeout.")
async def stop_bot(self): async def stop_bot(self):
async with self.lock: async with self.lock:
if self.bot: if self.bot:
for guild_id in list(self.voice_connections.keys()):
await self.leave_voice_channel(guild_id)
await self.bot.close() await self.bot.close()
self.bot = None self.bot = None
if self.bot_task: if self.bot_task:
await self.bot_task self.bot_task.cancel()
self.bot_task = None self.bot_task = None
self.voice_clients.clear() self.voice_connections.clear()
self._ready_event.clear() self._ready_event.clear()
LOGGER.info("Bot has been stopped.") 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): async def join_voice_channel(self, guild_id: int, channel_id: int, ng_threshold: int = 50, device_id: int = 4):
if not self.bot: if not self.bot: raise RuntimeError("Bot is not running.")
raise RuntimeError("Bot is not running.")
guild = self.bot.get_guild(guild_id) guild = self.bot.get_guild(guild_id)
if not guild: if not guild: raise ValueError("Guild not found.")
raise ValueError("Guild not found.") if not opus.is_loaded(): raise RuntimeError("Opus is not loaded.")
if not opus.is_loaded():
raise RuntimeError("Opus is not loaded.")
channel = guild.get_channel(channel_id) channel = guild.get_channel(channel_id)
if not isinstance(channel, VoiceChannel): if not isinstance(channel, VoiceChannel): raise ValueError("Channel is not a voice channel.")
raise ValueError("Channel is not a voice channel.") if guild_id in self.voice_connections: raise RuntimeError("Already connected to this guild's voice channel.")
if guild_id in self.voice_clients:
raise RuntimeError("Already connected to this guild's voice channel.")
try: try:
self._voice_ready_event.clear()
voice_client = await channel.connect(timeout=60.0, reconnect=True) voice_client = await channel.connect(timeout=60.0, reconnect=True)
LOGGER.debug("Voice Connected.") await asyncio.wait_for(self._voice_ready_event.wait(), timeout=15.0)
streamHandler = NoiseGate(
_input_device_index=device_id,
_voice_connection=voice_client,
_noise_gate_threshold=ng_threshold)
streamHandler.run()
LOGGER.debug("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}")
LOGGER.info("Waiting for bot to join voice...") # Create a single audio manager for this connection
try: audio_manager = AudioStreamManager(input_device_index=device_id)
await asyncio.wait_for(self._voice_ready_event.wait(), timeout=60.0)
LOGGER.info("Bot joined voice, returning.") # Create the noise-gated audio source
return audio_source = NoiseGateSource(audio_manager.get_stream(), threshold=ng_threshold)
except asyncio.TimeoutError:
LOGGER.error("Timeout waiting for bot to join voice.") # Play the source
raise RuntimeError("Bot failed to join voice within timeout.") voice_client.play(audio_source, after=lambda e: print(f'Player error: {e}') if e else None)
self.voice_connections[guild_id] = {
"client": voice_client,
"audio_manager": audio_manager
}
LOGGER.info(f"Joined guild {guild_id} and started audio stream.")
except Exception as e:
LOGGER.error(f"Failed to connect to voice channel: {e}", exc_info=True)
raise
async def leave_voice_channel(self, guild_id: int): async def leave_voice_channel(self, guild_id: int):
if not self.bot: if not self.bot: raise RuntimeError("Bot is not running.")
raise RuntimeError("Bot is not running.")
connection_info = self.voice_connections.get(guild_id)
if not connection_info: raise RuntimeError("Not connected to the specified guild's voice channel.")
voice_client = self.voice_clients.get(guild_id) voice_client = connection_info.get("client")
if not voice_client: if voice_client and voice_client.is_connected():
raise RuntimeError("Not connected to the specified guild's voice channel.") voice_client.stop()
await voice_client.disconnect()
await voice_client.disconnect() # Terminate the audio manager to release PyAudio resources
del self.voice_clients[guild_id] audio_manager = connection_info.get("audio_manager")
if audio_manager:
audio_manager.terminate()
del self.voice_connections[guild_id]
LOGGER.info(f"Left guild {guild_id} voice channel.") LOGGER.info(f"Left guild {guild_id} voice channel.")
async def load_opus(self): async def load_opus(self):
""" Load the proper OPUS library for the device being used """ # ... this method is unchanged ...
processor = platform.machine() processor = platform.machine()
script_dir = os.path.dirname(os.path.abspath(__file__)) script_dir = os.path.dirname(os.path.abspath(__file__))
LOGGER.debug("Processor: ", processor) LOGGER.debug(f"Processor: {processor}, OS: {os.name}")
if os.name == 'nt':
if processor == "AMD64":
opus.load_opus(os.path.join(script_dir, './opus/libopus_amd64.dll'))
LOGGER.info("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("Loaded OPUS library for aarch64")
return "aarch64"
elif processor == "armv7l":
opus.load_opus(os.path.join(script_dir, './opus/libopus_armv7l.so'))
LOGGER.info("Loaded OPUS library for armv7l")
return "armv7l"
async def set_presence(self, presence: str):
""" Set the presense (activity) of the bot """
try: try:
await self.bot.change_presence(activity=Activity(type=ActivityType.listening, name=presence)) if os.name == 'nt':
if processor == "AMD64":
opus.load_opus(os.path.join(script_dir, './opus/libopus_amd64.dll'))
LOGGER.info("Loaded OPUS library for AMD64")
else:
if processor == "aarch64":
opus.load_opus(os.path.join(script_dir, './opus/libopus_aarcch64.so'))
LOGGER.info("Loaded OPUS library for aarch64")
elif processor == "armv7l":
opus.load_opus(os.path.join(script_dir, './opus/libopus_armv7l.so'))
LOGGER.info("Loaded OPUS library for armv7l")
else:
opus.load_opus('libopus.so.0')
LOGGER.info(f"Loaded system OPUS library for {processor}")
except Exception as e:
LOGGER.error(f"Failed to load OPUS library: {e}")
raise RuntimeError("Could not load a valid Opus library. Voice functionality will fail.")
async def set_presence(self, system_name: str):
# ... this method is unchanged ...
if not self.bot or not self.bot.is_ready():
LOGGER.warning("Bot is not ready, cannot set presence.")
return
try:
activity = Activity(type=ActivityType.listening, name=system_name)
await self.bot.change_presence(activity=activity)
LOGGER.info(f"Bot presence set to 'Listening to {system_name}'")
except Exception as pe: except Exception as pe:
LOGGER.error(f"Unable to set presence: '{pe}'") LOGGER.error(f"Unable to set presence: '{pe}'")

View File

@@ -0,0 +1,70 @@
import csv
import json
from models import TalkgroupTag
from typing import List
from internal.logger import create_logger
LOGGER = create_logger(__name__)
def save_talkgroup_tags(talkgroup_tags: List[TalkgroupTag]) -> None:
"""
Writes a list of tags to the tags file.
Args:
talkgroup_tags (List[TalkgroupTag]): The list of TalkgroupTag instances.
"""
with open("/configs/active.cfg.tags.tsv", 'w', newline='', encoding='utf-8') as file:
writer = csv.writer(file, delimiter='\t', lineterminator='\n')
# Write rows
for tag in talkgroup_tags:
writer.writerow([tag.talkgroup, tag.tagDec])
def save_whitelist(talkgroup_tags: List[int]) -> None:
"""
Writes a list of talkgroups to the whitelists file.
Args:
talkgroup_tags (List[int]): The list of decimals to whitelist.
"""
with open("/configs/active.cfg.whitelist.tsv", 'w', newline='', encoding='utf-8') as file:
writer = csv.writer(file, delimiter='\t', lineterminator='\n')
# Write rows
for tag in talkgroup_tags:
writer.writerow([tag])
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()):
LOGGER.info(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
def get_current_system_from_config() -> str:
# Get the current config
with open('/configs/active.cfg.json', 'r') as f:
json_data = f.read()
if isinstance(json_data, str):
try:
data = json.loads(json_data)
except json.JSONDecodeError:
return None
elif isinstance(json_data, dict):
data = json_data
else:
return None
if "channels" in data and isinstance(data["channels"], list) and len(data["channels"]) > 0:
first_channel = data["channels"][0]
if "name" in first_channel:
return first_channel["name"]
return None

View File

@@ -3,6 +3,7 @@ import routers.op25_controller as op25_controller
import routers.pulse as pulse import routers.pulse as pulse
import routers.bot as bot import routers.bot as bot
from internal.logger import create_logger from internal.logger import create_logger
from internal.bot_manager import DiscordBotManager
# Initialize logging # Initialize logging
LOGGER = create_logger(__name__) LOGGER = create_logger(__name__)
@@ -10,6 +11,9 @@ LOGGER = create_logger(__name__)
# Define FastAPI app # Define FastAPI app
app = FastAPI() app = FastAPI()
app.include_router(op25_controller.router, prefix="/op25") # Initialize Discord Bot Manager
bot_manager_instance = DiscordBotManager()
app.include_router(op25_controller.create_op25_router(bot_manager=bot_manager_instance), prefix="/op25")
app.include_router(pulse.router, prefix="/pulse") app.include_router(pulse.router, prefix="/pulse")
app.include_router(bot.router, prefix="/bot") app.include_router(bot.create_bot_router(bot_manager=bot_manager_instance), prefix="/bot")

View File

@@ -5,53 +5,54 @@ from internal.logger import create_logger
LOGGER = create_logger(__name__) LOGGER = create_logger(__name__)
# Define FastAPI app # Function to create router
router = APIRouter() def create_bot_router(bot_manager: DiscordBotManager):
router = APIRouter()
# Initialize Discord Bot Manager # API Endpoints
bot_manager = DiscordBotManager() @router.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:
LOGGER.error(f"Error starting bot: {e}")
raise HTTPException(status_code=400, detail=str(e))
# API Endpoints @router.post("/stop_bot")
@router.post("/start_bot") async def stop_bot():
async def start_bot(config: BotConfig): try:
try: await bot_manager.stop_bot()
await bot_manager.start_bot(config.token) return {"status": "Bot stopped successfully."}
return {"status": "Bot started successfully."} except Exception as e:
except Exception as e: LOGGER.error(f"Error stopping bot: {e}")
LOGGER.error(f"Error starting bot: {e}") raise HTTPException(status_code=400, detail=str(e))
raise HTTPException(status_code=400, detail=str(e))
@router.post("/stop_bot") @router.post("/join_voice")
async def stop_bot(): async def join_voice_channel(request: VoiceChannelJoinRequest):
try: try:
await bot_manager.stop_bot() await bot_manager.join_voice_channel(request.guild_id, request.channel_id)
return {"status": "Bot stopped successfully."} return {"status": f"Joined guild {request.guild_id} voice channel {request.channel_id}."}
except Exception as e: except Exception as e:
LOGGER.error(f"Error stopping bot: {e}") LOGGER.error(f"Error joining voice channel: {e}")
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
@router.post("/join_voice") @router.post("/leave_voice")
async def join_voice_channel(request: VoiceChannelJoinRequest): async def leave_voice_channel(request: VoiceChannelLeaveRequest):
try: try:
await bot_manager.join_voice_channel(request.guild_id, request.channel_id) await bot_manager.leave_voice_channel(request.guild_id)
return {"status": f"Joined guild {request.guild_id} voice channel {request.channel_id}."} return {"status": f"Left guild {request.guild_id} voice channel."}
except Exception as e: except Exception as e:
LOGGER.error(f"Error joining voice channel: {e}") LOGGER.error(f"Error leaving voice channel: {e}")
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
@router.post("/leave_voice") @router.get("/status")
async def leave_voice_channel(request: VoiceChannelLeaveRequest): async def get_status():
try: status = {
await bot_manager.leave_voice_channel(request.guild_id) "bot_running": bot_manager.bot is not None and not bot_manager.bot.is_closed(),
return {"status": f"Left guild {request.guild_id} voice channel."} "connected_guilds": list(bot_manager.voice_connections.keys()),
except Exception as e: "active_token": bot_manager.token
LOGGER.error(f"Error leaving voice channel: {e}") }
raise HTTPException(status_code=400, detail=str(e)) return status
@router.get("/status") return router
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

View File

@@ -3,151 +3,124 @@ import subprocess
import os import os
import signal import signal
import json import json
import csv from models import ConfigGenerator, DecodeMode, ChannelConfig, DeviceConfig, TrunkingConfig, TrunkingChannelConfig, AudioConfig, TerminalConfig
from models import ConfigGenerator, DecodeMode, ChannelConfig, DeviceConfig, TrunkingConfig, TrunkingChannelConfig, AudioConfig, TerminalConfig, TalkgroupTag
from internal.logger import create_logger from internal.logger import create_logger
from typing import List from internal.bot_manager import DiscordBotManager
from internal.op25_config_utls import save_talkgroup_tags, save_whitelist, del_none_in_dict, get_current_system_from_config
router = APIRouter()
LOGGER = create_logger(__name__) LOGGER = create_logger(__name__)
op25_process = None op25_process = None
OP25_PATH = "/op25/op25/gr-op25_repeater/apps/" OP25_PATH = "/op25/op25/gr-op25_repeater/apps/"
OP25_SCRIPT = "run_multi-rx_service.sh" OP25_SCRIPT = "run_multi-rx_service.sh"
@router.post("/start") def create_op25_router(bot_manager: DiscordBotManager):
async def start_op25(): router = APIRouter()
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)
LOGGER.debug(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"}
@router.post("/generate-config")
async def generate_config(generator: ConfigGenerator):
try:
if generator.type == DecodeMode.P25:
channels = [ChannelConfig(
name=generator.systemName,
trunking_sysname=generator.systemName,
enable_analog="off",
demod_type="cqpsk",
cqpsk_tracking=True,
filter_type="rc"
)]
devices = [DeviceConfig()]
save_talkgroup_tags(generator.tags)
save_whitelist(generator.whitelist)
trunking = TrunkingConfig(
module="tk_p25.py",
chans=[TrunkingChannelConfig(
sysname=generator.systemName,
control_channel_list=','.join(generator.channels),
tagsFile="/configs/active.cfg.tags.tsv",
whitelist="/configs/active.cfg.whitelist.tsv"
)]
)
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:
generator = generator.config
channels = [ChannelConfig(
channelName=generator.systemName,
enableAnalog="on",
demodType="fsk4",
frequency=generator.frequency,
filterType="widepulse",
nbfmSquelch=generator.nbfmSquelch
)]
devices = [DeviceConfig(gain="LNA:32")]
config_dict = {
"channels": [channel.dict() for channel in channels],
"devices": [device.dict() for device in devices]
}
@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)
LOGGER.debug(op25_process)
return {"status": "OP25 started"}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
else: else:
raise HTTPException(status_code=400, detail="Invalid configuration type. Must be 'p25' or 'nbfm'.") return {"status": "OP25 already running"}
with open('/configs/active.cfg.json', 'w') as f: @router.post("/stop")
json.dump(del_none_in_dict(config_dict), f, indent=2) 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"}
return {"message": "Config exported to '/configs/active.cfg.json'"} @router.get("/status")
except Exception as e: async def get_status():
raise HTTPException(status_code=500, detail=str(e)) return {"status": "running" if op25_process else "stopped"}
def save_talkgroup_tags(talkgroup_tags: List[TalkgroupTag]) -> None: @router.post("/generate-config")
""" async def generate_config(generator: ConfigGenerator):
Writes a list of tags to the tags file. try:
if generator.type == DecodeMode.P25:
channels = [ChannelConfig(
name=generator.systemName,
trunking_sysname=generator.systemName,
enable_analog="off",
demod_type="cqpsk",
cqpsk_tracking=True,
filter_type="rc"
)]
devices = [DeviceConfig()]
save_talkgroup_tags(generator.tags)
save_whitelist(generator.whitelist)
trunking = TrunkingConfig(
module="tk_p25.py",
chans=[TrunkingChannelConfig(
sysname=generator.systemName,
control_channel_list=','.join(generator.channels),
tagsFile="/configs/active.cfg.tags.tsv",
whitelist="/configs/active.cfg.whitelist.tsv"
)]
)
Args: audio = AudioConfig()
talkgroup_tags (List[TalkgroupTag]): The list of TalkgroupTag instances.
"""
with open("/configs/active.cfg.tags.tsv", 'w', newline='', encoding='utf-8') as file:
writer = csv.writer(file, delimiter='\t', lineterminator='\n')
# Write rows
for tag in talkgroup_tags:
writer.writerow([tag.talkgroup, tag.tagDec])
def save_whitelist(talkgroup_tags: List[int]) -> None: terminal = TerminalConfig()
"""
Writes a list of talkgroups to the whitelists file.
Args: config_dict = {
talkgroup_tags (List[int]): The list of decimals to whitelist. "channels": [channel.dict() for channel in channels],
""" "devices": [device.dict() for device in devices],
with open("/configs/active.cfg.whitelist.tsv", 'w', newline='', encoding='utf-8') as file: "trunking": trunking.dict(),
writer = csv.writer(file, delimiter='\t', lineterminator='\n') "audio": audio.dict(),
# Write rows "terminal": terminal.dict()
for tag in talkgroup_tags: }
writer.writerow([tag])
def del_none_in_dict(d): elif generator.type == DecodeMode.ANALOG:
""" generator = generator.config
Delete keys with the value ``None`` in a dictionary, recursively. channels = [ChannelConfig(
channelName=generator.systemName,
enableAnalog="on",
demodType="fsk4",
frequency=generator.frequency,
filterType="widepulse",
nbfmSquelch=generator.nbfmSquelch
)]
devices = [DeviceConfig(gain="LNA:32")]
This alters the input so you may wish to ``copy`` the dict first. config_dict = {
""" "channels": [channel.dict() for channel in channels],
for key, value in list(d.items()): "devices": [device.dict() for device in devices]
LOGGER.info(f"Key: '{key}'\nValue: '{value}'") }
if value is None:
del d[key] else:
elif isinstance(value, dict): raise HTTPException(status_code=400, detail="Invalid configuration type. Must be 'p25' or 'nbfm'.")
del_none_in_dict(value)
elif isinstance(value, list): with open('/configs/active.cfg.json', 'w') as f:
for iterative_value in value: json.dump(del_none_in_dict(config_dict), f, indent=2)
del_none_in_dict(iterative_value)
return d # For convenience # Set the presence of the bot (if it's online)
await bot_manager.set_presence(generator.systemName)
return {"message": "Config exported to '/configs/active.cfg.json'"}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/update-presence")
async def update_presence():
current_system = get_current_system_from_config()
if not current_system:
raise HTTPException(status_code=500, detail="Unable to get current system.")
await bot_manager.set_presence(current_system)
return current_system
return router

15
op25-liq.service Normal file
View File

@@ -0,0 +1,15 @@
[Unit]
Description=op25-liq
After=syslog.target network.target nss-lookup.target network-online.target
Requires=network-online.target
[Service]
User=1000
Group=1000
WorkingDirectory=/op25/op25/gr-op25_repeater/apps
ExecStart=/usr/bin/liquidsoap op25.liq
RestartSec=5
Restart=on-failure
[Install]
WantedBy=multi-user.target

View File

@@ -1,54 +0,0 @@
#!/usr/bin/liquidsoap
# Example liquidsoap streaming from op25 to icecast
# (c) 2019-2021 gnorbury@bondcar.com, wllmbecks@gmail.com
#
set("log.stdout", true)
set("log.file", false)
set("log.level", 1)
# Make the native sample rate compatible with op25
set("frame.audio.samplerate", 8000)
input = mksafe(input.external(buffer=0.25, channels=2, samplerate=8000, restart_on_error=false, "./audio.py -x 1.5 -s"))
# Consider increasing the buffer value on slow systems such as RPi3. e.g. buffer=0.25
# Longer buffer results in less choppy audio but at the expense of increased latency.
# OPTIONAL AUDIO SIGNAL PROCESSING BLOCKS
# Uncomment to enable
#
# High pass filter
#input = filter.iir.butterworth.high(frequency = 200.0, order = 4, input)
# Low pass filter
#input = filter.iir.butterworth.low(frequency = 3250.0, order = 4, input)
# Compression
input = compress(input, attack = 2.0, gain = 0.0, knee = 13.0, ratio = 2.0, release = 12.3, threshold = -18.0)
# Normalization
input = normalize(input, gain_max = 6.0, gain_min = -6.0, target = -16.0, threshold = -65.0)
# LOCAL AUDIO OUTPUT
# Uncomment the appropriate line below to enable local sound
#
# Default audio subsystem
#out (input)
#
# PulseAudio
#output.pulseaudio(input)
#
# ALSA
#output.alsa(input)
# ICECAST STREAMING
# Uncomment to enable output to an icecast server
# Change the "host", "password", and "mount" strings appropriately first!
# For metadata to work properly, the host address given here MUST MATCH the address in op25's meta.json file
#
output.icecast(%mp3(bitrate=16, samplerate=22050, stereo=false), description="op25", genre="Public Safety", url="", fallible=false, host="localhost", port=8000, mount="mountpoint", password="hackme", mean(input))

View File

@@ -4,5 +4,4 @@ numpy==1.24.3
uvicorn uvicorn
fastapi fastapi
pyaudio pyaudio
argparse argparse
pyaudio

View File

@@ -1,20 +0,0 @@
[supervisord]
nodaemon=true
[program:op25-liq]
command=/usr/bin/liquidsoap /op25/op25.liq
directory=/op25
autostart=true
autorestart=true
stderr_logfile=/var/log/supervisor/op25-liq.err.log
stdout_logfile=/var/log/supervisor/op25-liq.out.log
user=root
[program:drb-client-discord]
command=uvicorn main:app --host 0.0.0.0 --port 8001 --reload
directory=/app
autostart=true
autorestart=true
stderr_logfile=/var/log/supervisor/main_app.err.log
stdout_logfile=/var/log/supervisor/main_app.out.log
user=root