41 Commits

Author SHA1 Message Date
Logan Cusano
237a767af5 Fix default device PA ID
Some checks failed
Lint / lint (pull_request) Failing after 42s
2025-08-02 00:28:57 -04:00
Logan Cusano
22abbbb2a8 Revert start change 2025-08-02 00:27:30 -04:00
Logan Cusano
2286c0816a Manual mods to try and match old code 2025-08-02 00:23:40 -04:00
Logan Cusano
554df45826 Attempt to fix voice_client 2025-08-02 00:05:53 -04:00
Logan Cusano
03eaf6887e Fix var creation level 2025-08-01 23:49:07 -04:00
Logan Cusano
46c17e55f8 Fix opus loading 2025-08-01 23:48:11 -04:00
Logan Cusano
62357fb920 Fix manager token name 2025-08-01 23:38:56 -04:00
Logan Cusano
991cd95a0f Fix status when bot isn't running 2025-08-01 23:37:42 -04:00
Logan Cusano
11dc4b5792 Update status list 2025-08-01 23:34:10 -04:00
Logan Cusano
b2b15d3b7c Fix client when client ID is passed 2025-08-01 23:32:17 -04:00
Logan Cusano
fffe1511e0 Fix the import 2025-08-01 23:29:44 -04:00
Logan Cusano
a3c48cd651 If it aint broke dont fix it type shit 2025-08-01 23:27:57 -04:00
Logan Cusano
66e4e38e5e Comment disconnection logic to see if that resolves the issue as it seems the bot will disconnect at times and this logic kills it if it does 2025-07-22 22:23:12 -04:00
Logan Cusano
e8c79454a5 fix missing function 2025-07-14 22:26:57 -04:00
Logan Cusano
aee6e40792 Revert voice activity changes 2025-07-14 22:25:36 -04:00
Logan Cusano
84cef3119f revert 2025-07-14 22:20:22 -04:00
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 625 additions and 424 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,15 +1,15 @@
import audioop import audioop
import logging
import math import math
import time import time
import pyaudio import pyaudio
import discord import discord
import numpy import numpy
from internal.logger import create_logger
voice_connection = None voice_connection = None
LOGGER = create_logger(__name__) LOGGER = logging.getLogger("Discord_Radio_Bot.NoiseGateV2")
# noinspection PyUnresolvedReferences # noinspection PyUnresolvedReferences
@@ -30,15 +30,15 @@ class AudioStream:
if _input: if _input:
self.paInstance_kwargs['input_device_index'] = _input_device_index self.paInstance_kwargs['input_device_index'] = _input_device_index
else: else:
LOGGER.warning("[AudioStream.__init__]:\tInput was not enabled." LOGGER.warning(f"[AudioStream.__init__]:\tInput was not enabled."
" Reinitialize with '_input=True'") f" Reinitialize with '_input=True'")
if _output_device_index: if _output_device_index:
if _output: if _output:
self.paInstance_kwargs['output_device_index'] = _output_device_index self.paInstance_kwargs['output_device_index'] = _output_device_index
else: else:
LOGGER.warning("[AudioStream.__init__]:\tOutput was not enabled." LOGGER.warning(f"[AudioStream.__init__]:\tOutput was not enabled."
" Reinitialize with '_output=True'") f" Reinitialize with '_output=True'")
if _init_on_startup: if _init_on_startup:
# Init PyAudio instance # Init PyAudio instance
@@ -59,15 +59,15 @@ class AudioStream:
if self.paInstance_kwargs['input']: if self.paInstance_kwargs['input']:
self.paInstance_kwargs['input_device_index'] = _new_input_device_index self.paInstance_kwargs['input_device_index'] = _new_input_device_index
else: else:
LOGGER.warning("[AudioStream.init_stream]:\tInput was not enabled when initialized." LOGGER.warning(f"[AudioStream.init_stream]:\tInput was not enabled when initialized."
" Reinitialize with '_input=True'") f" Reinitialize with '_input=True'")
if _new_output_device_index: if _new_output_device_index:
if self.paInstance_kwargs['output']: if self.paInstance_kwargs['output']:
self.paInstance_kwargs['output_device_index'] = _new_output_device_index self.paInstance_kwargs['output_device_index'] = _new_output_device_index
else: else:
LOGGER.warning("[AudioStream.init_stream]:\tOutput was not enabled when initialized." LOGGER.warning(f"[AudioStream.init_stream]:\tOutput was not enabled when initialized."
" Reinitialize with '_output=True'") f" Reinitialize with '_output=True'")
self.close_if_open() self.close_if_open()
@@ -80,7 +80,7 @@ class AudioStream:
if self.stream.is_active(): if self.stream.is_active():
self.stream.stop_stream() self.stream.stop_stream()
self.stream.close() self.stream.close()
LOGGER.debug("[ReopenStream.close_if_open]:\t Stream was open; It was closed.") 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): def list_devices(self, _display_input_devices: bool = True, _display_output_devices: bool = True):
LOGGER.info('Getting a list of the devices connected') LOGGER.info('Getting a list of the devices connected')
@@ -126,7 +126,7 @@ class NoiseGate(AudioStream):
def run(self) -> None: def run(self) -> None:
global voice_connection global voice_connection
# Start the audio stream # Start the audio stream
LOGGER.debug("Starting stream") LOGGER.debug(f"Starting stream")
self.stream.start_stream() self.stream.start_stream()
# Start the stream to discord # Start the stream to discord
self.core() self.core()
@@ -139,15 +139,15 @@ class NoiseGate(AudioStream):
time.sleep(.2) time.sleep(.2)
if not voice_connection.is_playing(): if not voice_connection.is_playing():
LOGGER.debug("Playing stream to discord") LOGGER.debug(f"Playing stream to discord")
voice_connection.play(self.NGStream, after=self.core) voice_connection.play(self.NGStream, after=self.core)
async def close(self): async def close(self):
LOGGER.debug("Closing") LOGGER.debug(f"Closing")
await voice_connection.disconnect() await voice_connection.disconnect()
if self.stream.is_active: if self.stream.is_active:
self.stream.stop_stream() self.stream.stop_stream()
LOGGER.debug("Stopping stream") LOGGER.debug(f"Stopping stream")
# noinspection PyUnresolvedReferences # noinspection PyUnresolvedReferences
@@ -155,7 +155,7 @@ class NoiseGateStream(discord.AudioSource):
def __init__(self, _stream): def __init__(self, _stream):
super(NoiseGateStream, self).__init__() super(NoiseGateStream, self).__init__()
self.stream = _stream # The actual audio stream object 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 = 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.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 self.process_set_count = 0 # Counts how many processes have been made

View File

@@ -1,173 +1,336 @@
import asyncio import argparse
import platform import platform
import os import os
from discord import VoiceClient, VoiceChannel, opus, Activity, ActivityType, Intents import asyncio
import discord
from discord.ext import commands from discord.ext import commands
from typing import Optional, Dict from internal.NoiseGatev2 import NoiseGate # Assuming NoiseGatev2.py is in the same directory
from internal.NoiseGatev2 import NoiseGate
from internal.logger import create_logger
LOGGER = create_logger(__name__) # --- Opus Library Loading ---
def load_opus():
"""Loads the correct opus library for the operating system."""
try:
script_dir = os.path.dirname(os.path.abspath(__file__))
if os.name == 'nt':
processor = platform.machine()
if processor == "AMD64":
print("Loaded OPUS library for Windows AMD64")
discord.opus.load_opus(os.path.join(script_dir, './opus/libopus_amd64.dll'))
else:
print(f"Unsupported Windows processor: {processor}. Opus may not work.")
else:
processor = platform.machine()
print(f"Processor: {processor}")
if processor == "aarch64":
print("Loaded OPUS library for aarch64")
discord.opus.load_opus(os.path.join(script_dir, './opus/libopus_aarcch64.so'))
elif processor == "armv7l":
print("Loaded OPUS library for armv7l")
discord.opus.load_opus(os.path.join(script_dir, './opus/libopus_armv7l.so'))
else:
print(f"Attempting to load a generic opus library for {processor}")
discord.opus.load_opus(os.path.join(script_dir, './opus/libopus.so.0'))
# Configure discord intents if discord.opus.is_loaded():
intents = Intents.default() print("Opus library loaded successfully.")
intents.voice_states = True return True
intents.guilds = True else:
print("Opus library failed to load.")
return False
class DiscordBotManager: except Exception as e:
def __init__(self): print(f"Error loading opus library: {e}")
self.bot: Optional[commands.Bot] = None return False
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()
self._ready_event = asyncio.Event()
self._voice_ready_event = asyncio.Event()
async def start_bot(self, token: str): # --- Voice Cog for Multi-Server Management ---
async with self.lock: class VoiceCog(commands.Cog):
if self.bot and not self.bot.is_closed(): """Cog to handle all voice-related commands and state management."""
raise RuntimeError("Bot is already running.") def __init__(self, bot, device_id, ng_threshold):
if self.bot_task and not self.bot_task.done(): self.bot = bot
raise RuntimeError("Bot is already running.") self.device_id = device_id
self.ng_threshold = ng_threshold
self.voice_states = {} # { guild_id: NoiseGate_instance }
self.token = token @commands.Cog.listener()
self.bot = commands.Bot(command_prefix="!", intents=intents) async def on_ready(self):
print(f'Logged in as {self.bot.user} (ID: {self.bot.user.id})')
print('------')
@self.bot.event # Internal API method to join a voice channel
async def on_ready(): async def internal_join_voice_channel(self, guild_id: int, channel_id: int):
LOGGER.info(f'Logged in as {self.bot.user}') guild = self.bot.get_guild(guild_id)
# Set the event when on_ready is called if not guild:
self._ready_event.set() print(f"Guild with ID {guild_id} not found.")
return False
@self.bot.event channel = guild.get_channel(channel_id)
async def on_voice_state_update(member, before, after): if not channel or not isinstance(channel, discord.VoiceChannel):
# Check if the bot was disconnected print(f"Voice channel with ID {channel_id} not found in guild {guild.name}.")
if member == self.bot.user and after.channel is None: return False
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: # Get voice client for this guild
print(f"{member.name} joined voice channel {after.channel.name}") current_voice_client = discord.utils.get(self.bot.voice_clients, guild=guild)
self._voice_ready_event.set()
# Load Opus for the current CPU if current_voice_client:
await self.load_opus() if current_voice_client.channel == channel:
print(f"Already in channel: {channel.name} in guild {guild.name}.")
voice_client_to_use = current_voice_client # Use existing
else:
await current_voice_client.move_to(channel)
print(f"Moved to channel: {channel.name} in guild {guild.name}.")
voice_client_to_use = current_voice_client # Use moved existing
else:
try:
voice_client_to_use = await channel.connect(timeout=60.0, reconnect=True)
print(f"Connected to channel: {channel.name} in guild {guild.name}.")
except Exception as e:
print(f"Failed to connect to {channel.name} in guild {guild.name}. Error: {e}")
return False
# Create the task to run the bot in the background if not voice_client_to_use:
self.bot_task = self.loop.create_task(self.bot.start(token)) print(f"Failed to obtain a valid VoiceClient object for guild {guild.name}.")
return False
# Wait for the on_ready event to be set by the bot task print("Selected voice client to use.")
LOGGER.info("Waiting for bot to become ready...")
try:
await asyncio.wait_for(self._ready_event.wait(), timeout=60.0)
LOGGER.info("Bot is ready, start_bot returning.")
return
except asyncio.TimeoutError:
LOGGER.error("Timeout waiting for bot to become ready. Bot might have failed to start.")
if self.bot_task and not self.bot_task.done():
self.bot_task.cancel()
raise RuntimeError("Bot failed to become ready within timeout.")
async def stop_bot(self): if discord.opus.is_loaded():
async with self.lock: # Create and start the NoiseGate audio stream for this server
if self.bot: # Ensure the correct voice client is passed
await self.bot.close() stream_handler = NoiseGate(
self.bot = None _voice_connection=voice_client_to_use, # Corrected: use the unified variable
if self.bot_task: _noise_gate_threshold=self.ng_threshold,
await self.bot_task _input_device_index=self.device_id
self.bot_task = None )
self.voice_clients.clear() self.voice_states[guild.id] = stream_handler
self._ready_event.clear() stream_handler.run()
LOGGER.info("Bot has been stopped.") print(f"Started audio stream for server: {guild.name}")
return True
else:
print("Opus library not loaded. Cannot start audio stream.")
# Disconnect if opus isn't loaded but a connection was made
if voice_client_to_use:
await voice_client_to_use.disconnect()
return False
async def join_voice_channel(self, guild_id: int, channel_id: int, ng_threshold: int = 50, device_id: int = 4): # Internal API method to leave a voice channel
if not self.bot: async def internal_leave_voice_channel(self, guild_id: int):
raise RuntimeError("Bot is not running.") if guild_id not in self.voice_states:
print(f"Not currently in a voice channel on guild ID {guild_id}.")
return False
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.") print(f"Guild with ID {guild_id} not found.")
return False
if not opus.is_loaded(): voice_client = discord.utils.get(self.bot.voice_clients, guild=guild)
raise RuntimeError("Opus is not loaded.")
channel = guild.get_channel(channel_id) if not voice_client:
if not isinstance(channel, VoiceChannel): print(f"Bot not in a voice channel in guild {guild.name}, but state exists. Cleaning up.")
raise ValueError("Channel is not a voice channel.") del self.voice_states[guild.id]
return True
if guild_id in self.voice_clients: stream_handler = self.voice_states[guild.id]
raise RuntimeError("Already connected to this guild's voice channel.") await stream_handler.close() # Close the NoiseGate stream
del self.voice_states[guild.id]
print(f"Disconnected and stopped the audio stream for guild: {guild.name}")
return True
try: # Discord command for joining (for direct user interaction)
voice_client = await channel.connect(timeout=60.0, reconnect=True) @commands.command(name='join')
LOGGER.debug("Voice Connected.") async def join_command(self, ctx, *, channel: discord.VoiceChannel = None):
streamHandler = NoiseGate( if not channel:
_input_device_index=device_id, if ctx.author.voice:
_voice_connection=voice_client, channel = ctx.author.voice.channel
_noise_gate_threshold=ng_threshold) else:
streamHandler.run() await ctx.send("You are not connected to a voice channel. Please specify one to join.")
LOGGER.debug("Stream is running.") return
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...") success = await self.internal_join_voice_channel(ctx.guild.id, channel.id)
try: if success:
await asyncio.wait_for(self._voice_ready_event.wait(), timeout=60.0) await ctx.send(f"Connected to channel: {channel.name}.")
LOGGER.info("Bot joined voice, returning.") else:
await ctx.send(f"Failed to connect to {channel.name}.")
# Discord command for leaving (for direct user interaction)
@commands.command(name='leave')
async def leave_command(self, ctx):
success = await self.internal_leave_voice_channel(ctx.guild.id)
if success:
await ctx.send("Disconnected and stopped the audio stream.")
else:
await ctx.send("I am not currently in a voice channel on this server.")
@join_command.before_invoke
async def ensure_opus(self, ctx):
if not discord.opus.is_loaded():
await ctx.send("Opus audio library is not loaded. I cannot join a voice channel.")
raise commands.CommandError("Opus not loaded.")
# --- Discord Bot Manager Class ---
class DiscordBotManager:
def __init__(self, device_id: int = 1, ng_threshold: int = 50):
self.token = None
self.device_id = device_id
self.ng_threshold = ng_threshold
self.bot = None
self.voice_cog = None
self._bot_task = None # To hold the running bot task for graceful stopping
async def _setup_bot(self):
# Define bot intents
intents = discord.Intents.default()
intents.message_content = True # Required for commands
intents.guilds = True # Required to get guild objects by ID
intents.voice_states = True # Required to get voice channel info
self.bot = commands.Bot(command_prefix='!', intents=intents)
self.voice_cog = VoiceCog(self.bot, self.device_id, self.ng_threshold)
await self.bot.add_cog(self.voice_cog)
@self.bot.event
async def on_ready():
if not load_opus():
print("Failed to load Opus library. Bot cannot start voice features.")
return
print(f'Bot fully ready: {self.bot.user}')
# Set initial presence when the bot is ready
await self.set_presence("Broadcasting...", discord.Game)
async def start_bot(self, token: str):
if self.bot and self.bot.is_ready():
print("Bot is already running.")
return return
except asyncio.TimeoutError:
LOGGER.error("Timeout waiting for bot to join voice.") self.token = token
raise RuntimeError("Bot failed to join voice within timeout.")
await self._setup_bot()
print("Starting bot...")
try:
# Run the bot in a separate task so we can control it
self._bot_task = asyncio.create_task(self.bot.start(self.token))
# Wait for the bot to connect (optional, useful for ensuring it's ready)
await self.bot.wait_until_ready()
print("Bot started and is ready.")
except discord.LoginFailure:
print("Failed to login to Discord. Check your client ID (token).")
except Exception as e:
print(f"An error occurred while starting the bot: {e}")
async def stop_bot(self):
if not self.bot or not self.bot.is_ready():
print("Bot is not running or not ready.")
return
print("Stopping bot...")
# Clean up all active voice connections before stopping
for guild_id in list(self.voice_cog.voice_states.keys()):
await self.voice_cog.internal_leave_voice_channel(guild_id)
await self.bot.close()
if self._bot_task:
self._bot_task.cancel() # Cancel the bot's running task
try:
await self._bot_task # Await cancellation to ensure cleanup
except asyncio.CancelledError:
pass
print("Bot stopped.")
self.bot = None # Reset bot instance
async def join_voice_channel(self, guild_id: int, channel_id: int):
if not self.bot or not self.bot.is_ready():
print("Bot is not running or ready. Cannot join voice channel.")
return False
return await self.voice_cog.internal_join_voice_channel(guild_id, channel_id)
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 or not self.bot.is_ready():
raise RuntimeError("Bot is not running.") print("Bot is not running or ready. Cannot leave voice channel.")
return False
return await self.voice_cog.internal_leave_voice_channel(guild_id)
voice_client = self.voice_clients.get(guild_id) async def set_presence(self, name: str, activity_type: discord.Activity = discord.ActivityType.listening):
if not voice_client: if not self.bot or not self.bot.is_ready():
raise RuntimeError("Not connected to the specified guild's voice channel.") print("Bot is not running or ready. Cannot set presence.")
return
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("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 activity_type == discord.Game:
except Exception as pe: activity = discord.Game(name=name)
LOGGER.error(f"Unable to set presence: '{pe}'") elif activity_type == discord.Streaming:
activity = discord.Streaming(name=name, url="https://twitch.tv/your_stream_url_here") # Replace with actual URL
elif activity_type == discord.ActivityType.listening:
activity = discord.Activity(type=discord.ActivityType.listening, name=name)
elif activity_type == discord.ActivityType.watching:
activity = discord.Activity(type=discord.ActivityType.watching, name=name)
else:
print(f"Invalid activity type: {activity_type}. Defaulting to Game.")
activity = discord.Game(name=name)
await self.bot.change_presence(activity=activity)
print(f"Presence set to: {name} ({activity_type.__name__})")
except Exception as e:
print(f"Error setting presence: {e}")
# --- Example Usage / Main Entry Point ---
async def main_run():
parser = argparse.ArgumentParser(description="Discord Radio Bot Manager.")
parser.add_argument("clientId", type=str, help="The Discord bot's client token.")
parser.add_argument("deviceId", type=int, help="The ID of the audio input device to use.")
parser.add_argument("-n", "--NGThreshold", type=int, default=50, help="The noise gate threshold (default: 50).")
args = parser.parse_args()
# Instantiate the bot manager
bot_manager = DiscordBotManager(args.clientId, args.deviceId, args.NGThreshold)
# --- Start the bot ---
await bot_manager.start_bot()
# --- Example of how an external system (like your websocket) would interact ---
# You would replace this with your actual websocket logic
print("\nBot is running. You can now use internal_api calls or Discord commands.")
print("Example: Use !join <channel_id> in Discord.")
print("Example: Use !leave in Discord.")
print("Example: Simulating an external request to join a channel after 10 seconds...")
await asyncio.sleep(10) # Simulate delay
# Placeholder for guild_id and channel_id from your websocket server
# You would receive these from your websocket server
# For testing, you'll need to manually get a guild ID and channel ID where your bot is.
example_guild_id = 123456789012345678 # <<< REPLACE WITH AN ACTUAL GUILD ID
example_channel_id = 987654321098765432 # <<< REPLACE WITH AN ACTUAL VOICE CHANNEL ID
print(f"\nAttempting to join voice channel {example_channel_id} in guild {example_guild_id} via internal API...")
success_join = await bot_manager.join_voice_channel(example_guild_id, example_channel_id)
print(f"Internal join successful: {success_join}")
await asyncio.sleep(15) # Stay in channel for a bit
print("\nAttempting to leave voice channel via internal API...")
success_leave = await bot_manager.leave_voice_channel(example_guild_id)
print(f"Internal leave successful: {success_leave}")
await asyncio.sleep(5)
print("\nChanging bot presence to 'Streaming music'...")
await bot_manager.set_presence("Streaming music", discord.Streaming)
await asyncio.sleep(5)
print("\nChanging bot presence back to 'Idle'...")
await bot_manager.set_presence("Idle", discord.Game)
# Keep the bot running indefinitely until an external stop command or script exit
try:
while True:
await asyncio.sleep(3600) # Sleep for an hour, or until interrupted
except KeyboardInterrupt:
print("\nKeyboardInterrupt detected. Stopping bot...")
finally:
await bot_manager.stop_bot()
if __name__ == "__main__":
asyncio.run(main_run())

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_cog.voice_states.keys()) if bot_manager.bot else {},
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