18 Commits

Author SHA1 Message Date
Logan Cusano
6324f82789 Update the OP25 generator model to take either a string or an int
Some checks failed
release-tag / release-image (push) Successful in 1h14m31s
Lint / lint (push) Failing after 12s
2025-06-22 23:25:13 -04:00
Logan Cusano
3086da0e2b Update docker to use supervisor
Some checks failed
Lint / lint (push) Failing after 6s
release-tag / release-image (push) Has been cancelled
2025-05-29 01:02:07 -04:00
Logan Cusano
021f27d62e Add custom liquid and service files and updated docker
Some checks failed
release-tag / release-image (push) Successful in 1h14m39s
Lint / lint (push) Failing after 7s
2025-05-28 23:43:57 -04:00
Logan Cusano
d3e7e780f3 Update docker to include liquidsoap
Some checks failed
release-tag / release-image (push) Failing after 1m54s
Lint / lint (push) Failing after 32s
2025-05-28 23:28:56 -04:00
Logan Cusano
872bbf2965 Ignore reconnection logic when disconnecting
Some checks failed
release-tag / release-image (push) Successful in 1h14m15s
Lint / lint (push) Failing after 10s
2025-05-02 22:18:16 -04:00
Logan Cusano
e7956577d7 Replace old classes
Some checks failed
Lint / lint (push) Waiting to run
release-tag / release-image (push) Has been cancelled
2025-05-02 21:59:20 -04:00
Logan Cusano
c31984e2d8 Remove channel ID req from leave channel as it's not used
Some checks failed
Lint / lint (push) Waiting to run
release-tag / release-image (push) Has been cancelled
2025-05-02 21:57:28 -04:00
Logan Cusano
5191433e5d Add wait to joining voice
Some checks failed
release-tag / release-image (push) Successful in 1h14m57s
Lint / lint (push) Failing after 12s
2025-04-27 03:29:06 -04:00
Logan Cusano
9dfee88789 Add missed param
Some checks failed
Lint / lint (push) Waiting to run
release-tag / release-image (push) Has been cancelled
2025-04-27 03:18:50 -04:00
Logan Cusano
46ec27c359 Update bot manager to respond to start bot when bot is ready
Some checks failed
Lint / lint (push) Waiting to run
release-tag / release-image (push) Has been cancelled
2025-04-27 03:15:15 -04:00
Logan Cusano
75b2d0007d Add deploy option for make
Some checks failed
release-tag / release-image (push) Failing after 1m41s
Lint / lint (push) Successful in 20s
2025-03-08 20:17:04 -05:00
c616acd6af update gitignore
Some checks failed
release-tag / release-image (push) Failing after 1m49s
Lint / lint (push) Successful in 23s
2025-03-08 18:44:33 -05:00
Logan Cusano
7e44e0e803 Typo in build
Some checks failed
release-tag / release-image (push) Failing after 1m38s
Lint / lint (push) Successful in 19s
2025-03-04 22:04:36 -05:00
2418ac2701 Merge pull request 'Improving reconnection logic' (#2) from fix-disconnection-bug into master
Some checks failed
release-tag / release-image (push) Failing after 46s
Lint / lint (push) Successful in 19s
Reviewed-on: #2
2025-03-04 22:02:32 -05:00
f9d30b0c8b Linting
All checks were successful
Lint / lint (pull_request) Successful in 19s
2025-03-04 22:00:02 -05:00
a5ff9fa1be Merge branch 'master' into fix-disconnection-bug
Some checks failed
Lint / lint (pull_request) Failing after 18s
2025-03-04 21:47:41 -05:00
Logan Cusano
9f10914b8b Remove extra container
Some checks failed
Lint / lint (push) Successful in 1m6s
release-tag / release-image (push) Failing after 50s
2025-03-01 01:56:28 -05:00
Logan Cusano
2c3c372da1 Potential Workaround dns issue
Some checks failed
Lint / lint (push) Failing after 20s
release-tag / release-image (push) Failing after 20s
2025-03-01 01:36:08 -05:00
13 changed files with 179 additions and 62 deletions

View File

@@ -8,8 +8,6 @@ on:
jobs: jobs:
release-image: release-image:
runs-on: ubuntu-latest runs-on: ubuntu-latest
container:
image: catthehacker/ubuntu:act-latest
env: env:
DOCKER_LATEST: nightly DOCKER_LATEST: nightly
CONTAINER_NAME: drb-client-discord-bot CONTAINER_NAME: drb-client-discord-bot
@@ -52,7 +50,7 @@ jobs:
context: . context: .
file: ./Dockerfile file: ./Dockerfile
platforms: | platforms: |
linux/arm4 linux/arm64
push: true push: true
tags: | # replace it with your local IP and tags 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 }}:${{ steps.meta.outputs.REPO_VERSION }}

2
.gitignore vendored
View File

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

View File

@@ -26,7 +26,9 @@ 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
@@ -37,6 +39,9 @@ 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
@@ -66,5 +71,8 @@ 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
# Run the node script # Add Supervisord configuration
ENTRYPOINT ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8001", "--reload"] COPY supervisord.conf /etc/supervisor/conf.d/supervisord.conf
# Modify the ENTRYPOINT to run Supervisord
ENTRYPOINT ["/usr/bin/supervisord", "-n", "-c", "/etc/supervisor/conf.d/supervisord.conf"]

View File

@@ -23,6 +23,16 @@ run: build
--network=host \ --network=host \
$(IMAGE_NAME) $(IMAGE_NAME)
# Deploy docker
deploy: build
docker run --rm -d \
--privileged \
-v /dev:/dev \
-v $(shell pwd)/configs:/configs \
--name $(CONTAINER_NAME) \
--network=host \
$(IMAGE_NAME)
# Stop the Docker container # Stop the Docker container
stop: stop:
docker stop $(CONTAINER_NAME) docker stop $(CONTAINER_NAME)

View File

@@ -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(f"[AudioStream.__init__]:\tInput was not enabled." LOGGER.warning("[AudioStream.__init__]:\tInput was not enabled."
f" Reinitialize with '_input=True'") " 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(f"[AudioStream.__init__]:\tOutput was not enabled." LOGGER.warning("[AudioStream.__init__]:\tOutput was not enabled."
f" Reinitialize with '_output=True'") " 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(f"[AudioStream.init_stream]:\tInput was not enabled when initialized." LOGGER.warning("[AudioStream.init_stream]:\tInput was not enabled when initialized."
f" Reinitialize with '_input=True'") " 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(f"[AudioStream.init_stream]:\tOutput was not enabled when initialized." LOGGER.warning("[AudioStream.init_stream]:\tOutput was not enabled when initialized."
f" Reinitialize with '_output=True'") " 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(f"[ReopenStream.close_if_open]:\t Stream was open; It was closed.") 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): 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(f"Starting stream") LOGGER.debug("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(f"Playing stream to discord") LOGGER.debug("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(f"Closing") LOGGER.debug("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(f"Stopping stream") LOGGER.debug("Stopping stream")
# noinspection PyUnresolvedReferences # noinspection PyUnresolvedReferences

View File

@@ -22,6 +22,8 @@ class DiscordBotManager:
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()
self._ready_event = asyncio.Event()
self._voice_ready_event = asyncio.Event()
async def start_bot(self, token: str): async def start_bot(self, token: str):
async with self.lock: async with self.lock:
@@ -36,12 +38,17 @@ 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.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 # Check if the bot was disconnected
if member == self.bot.user and after.channel is None: if member == self.bot.user and after.channel is None:
guild_id = before.channel.guild.id 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...") LOGGER.info(f"Bot was disconnected from channel in guild {guild_id}. Attempting to reconnect...")
try: try:
await self.leave_voice_channel(guild_id) await self.leave_voice_channel(guild_id)
@@ -51,11 +58,28 @@ class DiscordBotManager:
await asyncio.sleep(2) await asyncio.sleep(2)
await self.join_voice_channel(guild_id, before.channel.id) 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:
print(f"{member.name} joined voice channel {after.channel.name}")
self._voice_ready_event.set()
# Load Opus for the current CPU # 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...")
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): async def stop_bot(self):
async with self.lock: async with self.lock:
if self.bot: if self.bot:
@@ -65,6 +89,7 @@ class DiscordBotManager:
await self.bot_task await self.bot_task
self.bot_task = None self.bot_task = None
self.voice_clients.clear() self.voice_clients.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):
@@ -87,18 +112,27 @@ class DiscordBotManager:
try: try:
voice_client = await channel.connect(timeout=60.0, reconnect=True) voice_client = await channel.connect(timeout=60.0, reconnect=True)
LOGGER.debug(f"Voice Connected.") LOGGER.debug("Voice Connected.")
streamHandler = NoiseGate( streamHandler = NoiseGate(
_input_device_index=device_id, _input_device_index=device_id,
_voice_connection=voice_client, _voice_connection=voice_client,
_noise_gate_threshold=ng_threshold) _noise_gate_threshold=ng_threshold)
streamHandler.run() streamHandler.run()
LOGGER.debug(f"Stream is running.") LOGGER.debug("Stream is running.")
self.voice_clients[guild_id] = voice_client self.voice_clients[guild_id] = voice_client
LOGGER.info(f"Joined guild {guild_id} voice channel {channel_id} and stream is running.") LOGGER.info(f"Joined guild {guild_id} voice channel {channel_id} and stream is running.")
except Exception as e: except Exception as e:
LOGGER.error(f"Failed to connect to voice channel: {e}") LOGGER.error(f"Failed to connect to voice channel: {e}")
LOGGER.info("Waiting for bot to join voice...")
try:
await asyncio.wait_for(self._voice_ready_event.wait(), timeout=60.0)
LOGGER.info("Bot joined voice, returning.")
return
except asyncio.TimeoutError:
LOGGER.error("Timeout waiting for bot to join voice.")
raise RuntimeError("Bot failed to join voice within timeout.")
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.")
@@ -119,16 +153,16 @@ class DiscordBotManager:
if os.name == 'nt': if os.name == 'nt':
if processor == "AMD64": if processor == "AMD64":
opus.load_opus(os.path.join(script_dir, './opus/libopus_amd64.dll')) opus.load_opus(os.path.join(script_dir, './opus/libopus_amd64.dll'))
LOGGER.info(f"Loaded OPUS library for AMD64") LOGGER.info("Loaded OPUS library for AMD64")
return "AMD64" return "AMD64"
else: else:
if processor == "aarch64": if processor == "aarch64":
opus.load_opus(os.path.join(script_dir, './opus/libopus_aarcch64.so')) opus.load_opus(os.path.join(script_dir, './opus/libopus_aarcch64.so'))
LOGGER.info(f"Loaded OPUS library for aarch64") LOGGER.info("Loaded OPUS library for aarch64")
return "aarch64" return "aarch64"
elif processor == "armv7l": elif processor == "armv7l":
opus.load_opus(os.path.join(script_dir, './opus/libopus_armv7l.so')) opus.load_opus(os.path.join(script_dir, './opus/libopus_armv7l.so'))
LOGGER.info(f"Loaded OPUS library for armv7l") LOGGER.info("Loaded OPUS library for armv7l")
return "armv7l" return "armv7l"
async def set_presence(self, presence: str): async def set_presence(self, presence: str):

View File

@@ -1,9 +1,4 @@
import asyncio from fastapi import FastAPI
import discord
from discord.ext import commands
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from typing import Optional, Dict
import routers.op25_controller as op25_controller 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

View File

@@ -1,15 +1,18 @@
from pydantic import BaseModel from pydantic import BaseModel
from typing import List, Optional from typing import List, Optional, Union
from enum import Enum from enum import Enum
class BotConfig(BaseModel): class BotConfig(BaseModel):
token: str token: str
class VoiceChannelRequest(BaseModel): class VoiceChannelJoinRequest(BaseModel):
guild_id: int guild_id: int
channel_id: int channel_id: int
class VoiceChannelLeaveRequest(BaseModel):
guild_id: int
class DecodeMode(str, Enum): class DecodeMode(str, Enum):
P25 = "P25" P25 = "P25"
DMR = "DMR" DMR = "DMR"
@@ -22,7 +25,7 @@ class TalkgroupTag(BaseModel):
class ConfigGenerator(BaseModel): class ConfigGenerator(BaseModel):
type: DecodeMode type: DecodeMode
systemName: str systemName: str
channels: List[str] channels: List[Union[int, str]]
tags: Optional[List[TalkgroupTag]] tags: Optional[List[TalkgroupTag]]
whitelist: Optional[List[int]] whitelist: Optional[List[int]]

View File

@@ -1,10 +1,5 @@
import asyncio
import discord
from discord.ext import commands
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
from pydantic import BaseModel from models import BotConfig, VoiceChannelJoinRequest, VoiceChannelLeaveRequest
from typing import Optional, Dict
from models import BotConfig, VoiceChannelRequest
from internal.bot_manager import DiscordBotManager from internal.bot_manager import DiscordBotManager
from internal.logger import create_logger from internal.logger import create_logger
@@ -36,7 +31,7 @@ async def stop_bot():
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
@router.post("/join_voice") @router.post("/join_voice")
async def join_voice_channel(request: VoiceChannelRequest): async def join_voice_channel(request: VoiceChannelJoinRequest):
try: try:
await bot_manager.join_voice_channel(request.guild_id, request.channel_id) await bot_manager.join_voice_channel(request.guild_id, request.channel_id)
return {"status": f"Joined guild {request.guild_id} voice channel {request.channel_id}."} return {"status": f"Joined guild {request.guild_id} voice channel {request.channel_id}."}
@@ -45,7 +40,7 @@ async def join_voice_channel(request: VoiceChannelRequest):
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
@router.post("/leave_voice") @router.post("/leave_voice")
async def leave_voice_channel(request: VoiceChannelRequest): async def leave_voice_channel(request: VoiceChannelLeaveRequest):
try: try:
await bot_manager.leave_voice_channel(request.guild_id) await bot_manager.leave_voice_channel(request.guild_id)
return {"status": f"Left guild {request.guild_id} voice channel."} return {"status": f"Left guild {request.guild_id} voice channel."}

View File

@@ -1,12 +1,12 @@
from fastapi import HTTPException, APIRouter from fastapi import HTTPException, APIRouter
from pydantic import BaseModel
import subprocess import subprocess
import os import os
import signal import signal
import json import json
import csv import csv
from models import * 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
router = APIRouter() router = APIRouter()
LOGGER = create_logger(__name__) LOGGER = create_logger(__name__)

54
op25.liq Normal file
View File

@@ -0,0 +1,54 @@
#!/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))

20
supervisord.conf Normal file
View File

@@ -0,0 +1,20 @@
[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