Move recording and Discord voice to PulseAudio
CI / lint (push) Failing after 26s
CI / test (push) Successful in 41s

This commit is contained in:
Logan Cusano
2026-08-04 19:53:45 -04:00
parent 9addce7716
commit efdbe7d803
11 changed files with 1382 additions and 270 deletions
+76
View File
@@ -0,0 +1,76 @@
"""
PulseAudio readiness helpers.
The PulseAudio daemon lives in the `op25` container and exposes its native
socket on the shared `pulse_socket` docker volume (mounted at /run/pulse in
both containers, with PULSE_SERVER=unix:/run/pulse/native).
`op25-container/docker-entrypoint.sh` waits up to ~10 s for that socket before
starting its own app, but the edge-node historically had *no* equivalent wait:
FFmpeg would be launched with `-f pulse` before the socket existed, fail
instantly, and the audio path would stay dead for the lifetime of the process.
This module is the missing wait.
NOTE on the source name: the op25 entrypoint starts pulseaudio with `-n`, which
skips /etc/pulse/system.pa entirely and loads modules from the command line
instead. That means the `set-default-source drb_sink.monitor` line in system.pa
is NOT applied at runtime, so `-i default` is unreliable. Always address the
monitor explicitly via settings.pulse_source (default "drb_sink.monitor").
"""
import asyncio
import os
import stat
from typing import Optional
from app.config import settings
from app.internal.logger import logger
DEFAULT_SOCKET_PATH = "/run/pulse/native"
POLL_INTERVAL = 0.5
def socket_path() -> str:
"""Resolve the PulseAudio socket path from PULSE_SERVER (`unix:/path` form)."""
server = os.environ.get("PULSE_SERVER", "")
if server.startswith("unix:"):
candidate = server[len("unix:"):].strip()
if candidate:
return candidate
return DEFAULT_SOCKET_PATH
def is_ready() -> bool:
"""True when the PulseAudio native socket exists and really is a socket."""
try:
return stat.S_ISSOCK(os.stat(socket_path()).st_mode)
except OSError:
return False
async def wait_until_ready(timeout: Optional[float] = None) -> bool:
"""
Block until the PulseAudio socket appears, or `timeout` seconds elapse.
Bounded on purpose — never hang the caller forever. Returns True if the
socket is present, False on timeout (caller decides whether to retry).
"""
limit = settings.pulse_wait_timeout if timeout is None else timeout
path = socket_path()
if is_ready():
return True
logger.info(f"Waiting up to {limit:.0f}s for PulseAudio socket at {path}")
waited = 0.0
while waited < limit:
await asyncio.sleep(POLL_INTERVAL)
waited += POLL_INTERVAL
if is_ready():
logger.info(f"PulseAudio socket ready after {waited:.1f}s.")
return True
logger.error(
f"PulseAudio socket {path} not present after {limit:.0f}s — "
"is the op25 container running? Audio capture will retry."
)
return False