WIP: implement-discord-module #4
@@ -3,28 +3,23 @@ import asyncio
|
||||
import socket
|
||||
import logging
|
||||
import threading
|
||||
import struct
|
||||
import subprocess
|
||||
import shlex
|
||||
import time
|
||||
from collections import deque
|
||||
from typing import Optional, List, Tuple
|
||||
from typing import Optional, List
|
||||
|
||||
LOGGER = logging.getLogger("discord_radio")
|
||||
|
||||
class UDPAudioSource(discord.AudioSource):
|
||||
"""
|
||||
A custom Discord AudioSource that reads raw PCM audio from a thread-safe buffer.
|
||||
This buffer is filled by the UDPListener running in the background.
|
||||
|
||||
It pipes the raw 8000Hz 16-bit audio into FFmpeg to convert it to
|
||||
Discord's required 48000Hz stereo Opus format.
|
||||
"""
|
||||
def __init__(self, buffer: deque):
|
||||
self.buffer = buffer
|
||||
# We start an FFmpeg process that reads from stdin (pipe) and outputs raw PCM (s16le) at 48k
|
||||
# discord.py then encodes this to Opus.
|
||||
# Note: We use a pipe here because we are feeding it chunks from our UDP buffer.
|
||||
|
||||
self.ffmpeg_process = subprocess.Popen(
|
||||
shlex.split(
|
||||
"ffmpeg -f s16le -ar 8000 -ac 1 -i pipe:0 -f s16le -ar 48000 -ac 2 pipe:1 -loglevel panic"
|
||||
@@ -80,19 +75,25 @@ class DiscordRadioBot(discord.Client):
|
||||
- Packet Forwarding (to keep Liquidsoap/Icecast working)
|
||||
- Manual Channel Move detection
|
||||
- Dynamic Token/Channel joining
|
||||
- Debug Stats logging
|
||||
"""
|
||||
def __init__(self, listen_port=23456, forward_ports: List[int] = None):
|
||||
intents = discord.Intents.default()
|
||||
super().__init__(intents=intents)
|
||||
|
||||
self.listen_port = listen_port
|
||||
self.forward_ports = forward_ports or [] # List of ports to forward UDP packets to (e.g. [23457])
|
||||
self.forward_ports = forward_ports or []
|
||||
|
||||
self.audio_buffer = deque(maxlen=160000) # Buffer ~10 seconds of audio
|
||||
self.udp_sock = None
|
||||
self.running = False
|
||||
self.voice_client: Optional[discord.VoiceClient] = None
|
||||
|
||||
# Debug Stats
|
||||
self.packets_received = 0
|
||||
self.packets_forwarded = 0
|
||||
self.last_log_time = time.time()
|
||||
|
||||
# Start the UDP Listener in a separate daemon thread immediately
|
||||
self.udp_thread = threading.Thread(target=self._udp_listener_loop, daemon=True)
|
||||
self.udp_thread.start()
|
||||
@@ -100,16 +101,14 @@ class DiscordRadioBot(discord.Client):
|
||||
def _udp_listener_loop(self):
|
||||
"""
|
||||
Runs in a background thread. Listens for UDP packets from OP25.
|
||||
1. Adds data to internal audio buffer for Discord.
|
||||
2. Forwards data to other local ports (for Liquidsoap).
|
||||
"""
|
||||
self.udp_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
self.udp_sock.bind(('127.0.0.1', self.listen_port))
|
||||
# Bind to 0.0.0.0 to ensure we catch traffic from any interface (Docker/Localhost)
|
||||
self.udp_sock.bind(('0.0.0.0', self.listen_port))
|
||||
self.udp_sock.settimeout(1.0)
|
||||
|
||||
LOGGER.info(f"UDP Audio Bridge listening on 127.0.0.1:{self.listen_port}")
|
||||
if self.forward_ports:
|
||||
LOGGER.info(f"Forwarding audio to: {self.forward_ports}")
|
||||
LOGGER.info(f"UDP Audio Bridge listening on 0.0.0.0:{self.listen_port}")
|
||||
LOGGER.info(f"Forwarding audio to: {self.forward_ports}")
|
||||
|
||||
forward_socks = []
|
||||
for port in self.forward_ports:
|
||||
@@ -120,56 +119,60 @@ class DiscordRadioBot(discord.Client):
|
||||
while self.running:
|
||||
try:
|
||||
data, addr = self.udp_sock.recvfrom(4096)
|
||||
self.packets_received += 1
|
||||
|
||||
# 1. Forward to Liquidsoap/Other tools
|
||||
for sock, port in forward_socks:
|
||||
# Send to localhost (where Liquidsoap should be listening)
|
||||
sock.sendto(data, ('127.0.0.1', port))
|
||||
self.packets_forwarded += 1
|
||||
|
||||
# 2. Add to Discord Buffer
|
||||
# We extend the deque with the raw bytes
|
||||
self.audio_buffer.extend(data)
|
||||
|
||||
# Periodic Debug Logging (Every 5 seconds, only if active)
|
||||
if time.time() - self.last_log_time > 5:
|
||||
if self.packets_received > 0:
|
||||
LOGGER.info(f"UDP Stats [5s]: Rx {self.packets_received} pkts | Tx {self.packets_forwarded} pkts | Buffer: {len(self.audio_buffer)} bytes")
|
||||
|
||||
# Reset counters
|
||||
self.packets_received = 0
|
||||
self.packets_forwarded = 0
|
||||
self.last_log_time = time.time()
|
||||
|
||||
except socket.timeout:
|
||||
continue
|
||||
except Exception as e:
|
||||
LOGGER.error(f"UDP Listener Error: {e}")
|
||||
# Prevent tight loop on socket fail
|
||||
asyncio.sleep(0.1)
|
||||
time.sleep(0.1)
|
||||
|
||||
async def on_ready(self):
|
||||
LOGGER.info(f'Discord Radio Bot connected as {self.user}')
|
||||
|
||||
async def on_voice_state_update(self, member, before, after):
|
||||
"""
|
||||
Handles manual moves. If the bot is moved to a new channel by a user,
|
||||
update our internal voice_client reference to ensure we keep streaming.
|
||||
Handles manual moves.
|
||||
"""
|
||||
if member.id == self.user.id:
|
||||
if after.channel is not None and before.channel != after.channel:
|
||||
LOGGER.info(f"Bot was moved to channel: {after.channel.name}")
|
||||
# discord.py handles the connection handoff automatically,
|
||||
# but we log it to confirm persistence.
|
||||
self.voice_client = member.guild.voice_client
|
||||
|
||||
async def start_session(self, token: str, channel_id: int):
|
||||
"""
|
||||
Connects the bot to Discord and joins the specified channel.
|
||||
This should be called when the 'start' command is received from C2.
|
||||
"""
|
||||
# Start the client in the background
|
||||
asyncio.create_task(self.start(token))
|
||||
|
||||
# Wait for login to complete (simple poll)
|
||||
# Wait for login to complete
|
||||
for _ in range(20):
|
||||
if self.is_ready():
|
||||
break
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
# Join Channel
|
||||
try:
|
||||
channel = self.get_channel(int(channel_id))
|
||||
if not channel:
|
||||
# If cache not ready, try fetching
|
||||
channel = await self.fetch_channel(int(channel_id))
|
||||
|
||||
if channel:
|
||||
|
||||
Reference in New Issue
Block a user