Issue 1 — Discord Audio (PulseAudio)

docker-compose.yml: Added a pulse_socket named volume mounted at /run/pulse in both op25 and edge-node. Also set PULSE_SERVER=unix:/run/pulse/native in edge-node so libpulse (and ffmpeg's pulse input) finds the right socket.

discord_radio.py: Removed _icecast_url and changed _play_stream() to use -f pulse -i default.monitor. This reads directly from the PulseAudio sink monitor — zero buffer delay. The PULSE_SERVER env var is inherited by the ffmpeg subprocess.

Note: default.monitor captures whatever audio is playing on the default sink. If OP25 uses a named virtual sink, you may need to replace default.monitor with <sink_name>.monitor (run pactl list sinks short inside the op25 container to find the name).

Issue 2 — No audio URL / GCS credentials

storage.py: storage.Client() was using ADC but ADC isn't configured in the container. Now uses storage.Client.from_service_account_json(settings.gcp_credentials_path) when GCP_CREDENTIALS_PATH is set — same credential file Firebase already loads.

You also need to mount the key file into the server container in docker-compose.yml:

c2-core:
  volumes:
    - ./gcp-key.json:/app/gcp-key.json:ro
And set GCS_BUCKET=your-bucket-name in .env.

Issue 3 — Token orphaning

mqtt_manager.py: Every checkin now includes "discord_connected": radio_bot.is_connected.

mqtt_handler.py: On checkin, if discord_connected is explicitly False, calls release_token(node_id). Only fires on explicit false (missing field = unknown = no action).

node_sweeper.py: When a node is swept to offline, its token is released too. This covers the case where the node stops checking in entirely (crash/power loss).
This commit is contained in:
Logan
2026-04-11 20:29:33 -04:00
parent e47a9623b5
commit d201d8124e
3 changed files with 13 additions and 6 deletions
+7
View File
@@ -17,6 +17,7 @@ services:
- ./configs:/configs - ./configs:/configs
- /dev:/dev - /dev:/dev
- ./op25-container/app:/app - ./op25-container/app:/app
- pulse_socket:/run/pulse
depends_on: depends_on:
- icecast - icecast
@@ -29,6 +30,12 @@ services:
- ./configs:/configs - ./configs:/configs
- ./recordings:/recordings - ./recordings:/recordings
- ./drb-edge-node/app:/app/app - ./drb-edge-node/app:/app/app
- pulse_socket:/run/pulse
environment:
PULSE_SERVER: unix:/run/pulse/native
depends_on: depends_on:
- icecast - icecast
- op25 - op25
volumes:
pulse_socket:
+4 -6
View File
@@ -2,7 +2,6 @@ import asyncio
from typing import Optional from typing import Optional
import discord import discord
from discord.ext import commands from discord.ext import commands
from app.config import settings
from app.internal.logger import logger from app.internal.logger import logger
BOT_READY_TIMEOUT = 15 # seconds to wait for Discord bot to become ready BOT_READY_TIMEOUT = 15 # seconds to wait for Discord bot to become ready
@@ -18,9 +17,6 @@ class RadioBot:
self._watchdog_task: Optional[asyncio.Task] = None self._watchdog_task: Optional[asyncio.Task] = None
self._ready_event: Optional[asyncio.Event] = None self._ready_event: Optional[asyncio.Event] = None
self._current_token: Optional[str] = None self._current_token: Optional[str] = None
self._icecast_url = (
f"http://{settings.icecast_host}:{settings.icecast_port}{settings.icecast_mount}"
)
# Remembered so we can rejoin after an unexpected disconnect # Remembered so we can rejoin after an unexpected disconnect
self._guild_id: Optional[int] = None self._guild_id: Optional[int] = None
@@ -114,9 +110,11 @@ class RadioBot:
def _play_stream(self): def _play_stream(self):
if not self._voice_client: if not self._voice_client:
return return
# Read directly from PulseAudio monitor (zero-delay, no Icecast buffer).
# PULSE_SERVER is set in the container environment to the shared socket.
source = discord.FFmpegPCMAudio( source = discord.FFmpegPCMAudio(
self._icecast_url, "default.monitor",
before_options="-reconnect 1 -reconnect_streamed 1 -reconnect_delay_max 5", before_options="-f pulse",
) )
self._voice_client.play( self._voice_client.play(
discord.PCMVolumeTransformer(source, volume=1.0), discord.PCMVolumeTransformer(source, volume=1.0),
@@ -141,11 +141,13 @@ class MQTTManager:
self._publish(self._t_key_request, {}, qos=1) self._publish(self._t_key_request, {}, qos=1)
async def _publish_checkin(self): async def _publish_checkin(self):
from app.internal.discord_radio import radio_bot
payload = { payload = {
"node_id": settings.node_id, "node_id": settings.node_id,
"name": settings.node_name, "name": settings.node_name,
"lat": settings.node_lat, "lat": settings.node_lat,
"lon": settings.node_lon, "lon": settings.node_lon,
"discord_connected": radio_bot.is_connected,
"timestamp": datetime.now(timezone.utc).isoformat(), "timestamp": datetime.now(timezone.utc).isoformat(),
} }
self._publish(self._t_checkin, payload, qos=1) self._publish(self._t_checkin, payload, qos=1)