Compare commits

..

3 Commits

Author SHA1 Message Date
Logan Cusano f1de157d69 Pad the tgid_change closes so a talkgroup switch stops clipping the tail
CI / lint (push) Failing after 4s
CI / test (push) Failing after 22s
The tgid_change and tgid_change_unlogged paths closed the outgoing
segment with no tail pad, on the reasoning that the new grant's
timestamp is an exact, already-known boundary. That is exact only in
control-channel time. The buffered audio lags control timestamps by
~1.5s (measured 0.84-1.62s across seven field calls), so slicing there
cut roughly the outgoing call's last 1.5s of speech - recordings ending
mid-word with ~0s trailing silence.

Both paths now pad, and the recorder's bounded tail wait blocks until
that audio has actually been captured. TAIL_WAIT_TIMEOUT_SECONDS goes
2.0 -> 4.0 so it can satisfy the 3.0s pad instead of giving up and
warning on every talkgroup switch.

The incoming call's pre-roll is served from the ring buffer, so the
delay costs it nothing. The two slices overlapping in the underlying
audio is correct: the stream genuinely contains one call's tail and
then the next call's start.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 23:53:27 -04:00
Logan Cusano ceb2836371 Raise tail pad to 3s so short transmissions are not clipped
The recording window is anchored to OP25 control-channel timestamps, but
the buffered audio lags those by roughly 1.5s. Trim logs across seven
calls measured the offset at 0.84-1.62s, consistently present.

At a 1.0s pad a short call closed its window before the voice arrived:
a 0.97s control-channel call closed at T+1.97 while voice started around
T+1.5, capturing ~0.4s of speech and cutting mid-word. Confirmed by a
0.57s file whose final 0.10s measured -12.2dB against its own -18.2dB
average - clipped speech, not a tail - and by two short calls that
logged no trim at all because no trailing silence remained.

Being generous is free here: trim_silence already strips trailing
silence back to the guard margin before upload, so long calls are
unaffected while short ones gain the window they need. Over-capture
costs nothing; under-capture loses words permanently.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 22:34:17 -04:00
Logan Cusano 7c4a3f2f20 Make PulseAudio readiness mean a live connection, clear stale socket state
The pulse_socket named volume survives container recreation, so after a
compose recreate the previous container's /run/pulse/pid and native
socket were still present. PulseAudio read the stale pid file, decided a
daemon was already running, and refused to start:

    E: [pulseaudio] pid.c: Daemon already running.

The entrypoint still reported "PulseAudio socket ready" because it only
checked that the socket file existed - and a stale one did. Capture then
failed in a restart loop against a dead daemon.

Readiness in both the op25 entrypoint and drb-edge-node now means a
pactl probe actually succeeds. Stale pid/socket are removed only when
that probe fails, so a live daemon's socket is never deleted.

pulseaudio-utils was missing from the edge-node image (only libpulse0
was installed), so no pactl binary existed there at all - added.

Capture exits are now classified: a missing source logs at ERROR and
names the configured PULSE_SOURCE, rather than looking identical to
"daemon not up yet". Retrying forever against a wrong source name is how
the April PulseAudio failure stayed hidden.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 22:10:28 -04:00
10 changed files with 516 additions and 76 deletions
+7 -3
View File
@@ -38,9 +38,13 @@ CALL_IDLE_TIMEOUT=3
# Seconds of audio kept after the last transmission ends. This is the only
# headroom protecting the final word of a transmission — usually the disposition
# or the address. Measured at 0.5s it left ~0.3s of real margin and one recording
# ended mid-word, hence 1.0.
CALL_TAIL_PAD_SECONDS=1.0
# or the address. Raised 1.0 -> 3.0 after field measurement showed the
# grant-to-speech offset is ~0.84-1.62s (typically ~1.5s): at 1.0s pad, short
# calls had their recording window close before the voice even started,
# clipping speech mid-word. Safe to be generous — trim_silence already strips
# the extra back off long calls before upload, so only short transmissions
# actually benefit from the larger window.
CALL_TAIL_PAD_SECONDS=3.0
# Strip leading/trailing dead air before upload. ~63% of an untrimmed recording
# is silence, which costs Whisper spend and makes it hallucinate text that was
+1
View File
@@ -5,6 +5,7 @@ RUN apt-get update && apt-get install -y \
libopus0 \
libopus-dev \
libpulse0 \
pulseaudio-utils \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
+23 -4
View File
@@ -47,10 +47,29 @@ class Settings(BaseSettings):
# Audio kept after the observed end of the last transmission. The srcaddr
# 1→0 edge can be up to one poll (0.5 s) late and the encoder adds its own
# latency, so this is the only headroom protecting the last word of a
# transmission — which is usually the disposition or the address. Field
# measurement at 0.5 s left only 0.290.37 s of real trailing margin and one
# recording ended mid-word, hence 1.0 s.
call_tail_pad_seconds: float = 1.0
# transmission — which is usually the disposition or the address.
#
# Raised 1.0 -> 3.0 after field measurement showed the recording WINDOW
# (anchored to OP25 control-channel timestamps) closing well before the
# actual voice audio arrives: grant->speech offset measured 0.84-1.62s
# across 7 calls (~1.5s typical). At the old 1.0s pad, a short
# transmission (e.g. a 0.97s control-channel call) had its window close
# at T+1.97 while voice didn't start until ~T+1.5 — leaving ~0.4s of
# captured speech, clipped mid-word. Confirmed by a 0.57s output file
# whose final 0.10s measured -12.2dB, louder than its own -18.2dB
# average (i.e. clipped speech, not trailing silence), and by two short
# calls that produced no "Trimmed" log line at all because there was no
# trailing silence left to trim.
#
# Safe to be generous here: trim_silence already strips trailing silence
# back to trim_silence_guard_seconds before upload, so a larger pad costs
# long calls nothing (the extra is trimmed away) while giving short
# transmissions enough window to actually capture the voice. Over-capture
# is free; under-capture loses words permanently. Do not tune this back
# down without new field data showing the grant->speech offset has
# shrunk — see DEFERRED.md for the call_idle_timeout coupling this value
# now sits at.
call_tail_pad_seconds: float = 3.0
# Strip leading/trailing dead air before upload. ~63% of a typical recording
# is silence (the grant→speech delay plus the tail pad), which inflates
+61 -3
View File
@@ -89,13 +89,30 @@ MAX_RECORDING_BYTES = MAX_RECORDING_SECONDS * _MP3_BYTES_PER_SECOND * 4
# immediately therefore cuts the tail short — which costs the last word of the
# transmission, usually the disposition or the address. Bounded so a dead capture
# can never hang the upload path.
TAIL_WAIT_TIMEOUT_SECONDS = 2.0
#
# Must exceed settings.call_tail_pad_seconds (default 3.0), otherwise a
# tgid_change close — which pads past a timestamp that is still ~now — gives up
# before the padded audio has been captured and warns on every talkgroup switch.
TAIL_WAIT_TIMEOUT_SECONDS = 4.0
TAIL_WAIT_POLL_SECONDS = 0.05
# Backoff bounds for restarting a dead capture process.
RESTART_BACKOFF_MIN = 1.0
RESTART_BACKOFF_MAX = 15.0
# Substrings looked for in FFmpeg's stderr to classify why capture exited.
# "No such process" is what FFmpeg's pulse input prints when the daemon is up
# but the named source does not exist — a real PULSE_SOURCE misconfiguration,
# not a startup race. This is exactly the failure mode that hid unnoticed
# behind a generic "restarting" log in the April outage: silently retrying
# forever against a wrong source name looks identical to a normal startup
# wait unless it is logged differently.
_SOURCE_MISSING_MARKERS = ("no such process", "no such device")
# "Connection refused"/"Connection failure" is what the pulse client library
# prints when nothing is listening on the socket at all — expected while the
# op25 container's daemon is still coming up.
_NO_DAEMON_MARKERS = ("connection refused", "connection failure")
@dataclass
class _ActiveRecording:
@@ -154,6 +171,10 @@ class CallRecorder:
# Active recording state (None when idle)
self._active: Optional[_ActiveRecording] = None
# Last few lines of the most recent FFmpeg stderr, used to classify
# why a capture process exited (see _classify_capture_exit).
self._last_stderr_lines: deque[str] = deque(maxlen=10)
# ------------------------------------------------------------------
# Lifecycle
# ------------------------------------------------------------------
@@ -206,7 +227,7 @@ class CallRecorder:
continue
await self._run_capture()
logger.warning("PulseAudio capture process exited — restarting.")
self._log_capture_exit()
except asyncio.CancelledError:
await self._terminate_proc()
raise
@@ -220,6 +241,7 @@ class CallRecorder:
async def _run_capture(self) -> None:
cmd = self._ffmpeg_command()
logger.info(f"Starting capture: ffmpeg -f pulse -i {settings.pulse_source}")
self._last_stderr_lines.clear()
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
@@ -253,12 +275,48 @@ class CallRecorder:
line = await proc.stderr.readline()
if not line:
return
logger.warning(f"ffmpeg(pulse): {line.decode(errors='replace').strip()}")
text = line.decode(errors="replace").strip()
self._last_stderr_lines.append(text)
logger.warning(f"ffmpeg(pulse): {text}")
except asyncio.CancelledError:
return
except Exception:
return
def _log_capture_exit(self) -> None:
"""
Log why the just-finished capture process exited, distinguishing the
two failure modes that matter operationally instead of one generic
"restarting" line for both:
- no daemon / connection refused: infrastructure isn't up yet. This
is expected during startup/op25 restarts, so it stays at INFO —
the retry loop above already handles it.
- daemon up but the named source is missing: almost always a real
PULSE_SOURCE misconfiguration. This gets a loud, distinct ERROR
naming the configured source, because silently retrying forever
against a wrong source name is exactly how this hid in the past.
"""
text = " ".join(self._last_stderr_lines).lower()
if any(marker in text for marker in _SOURCE_MISSING_MARKERS):
logger.error(
f"PulseAudio capture exited: source '{settings.pulse_source}' does not exist on the "
"daemon (FFmpeg reported 'No such process'). This looks like a real PULSE_SOURCE "
"misconfiguration or a missing drb_sink — retrying will not fix it by itself. "
"Restarting anyway."
)
return
if any(marker in text for marker in _NO_DAEMON_MARKERS):
logger.info(
"PulseAudio capture exited: daemon not accepting connections — "
"infrastructure still coming up, restarting."
)
return
logger.warning("PulseAudio capture process exited — restarting.")
async def _terminate_proc(self) -> None:
proc, self._proc = self._proc, None
if proc is None or proc.returncode is not None:
+29 -6
View File
@@ -58,8 +58,24 @@ def _tail_pad() -> float:
edge (up to one poll late) plus encoder latency never clips the tail.
Read live from settings (env CALL_TAIL_PAD_SECONDS) rather than frozen into a
module constant, so it is tunable per node. See the setting for why the
default moved 0.51.0.
module constant, so it is tunable per node. See the setting in config.py for
why the default moved 1.03.0 (short calls' recording window was closing
before the ~1.5s grant→speech offset let voice audio even start).
All three close paths use this pad: idle_timeout, tgid_change, and
tgid_change_unlogged. An earlier version of this docstring claimed the
latter two close at "an exact, already-known boundary" (the new grant's
timestamp, or the same poll tick) and so intentionally added no pad — THAT
REASONING WAS WRONG and produced real truncated recordings. The boundary is
exact only in CONTROL-CHANNEL time; the buffered AUDIO lags control-channel
timestamps by ~1.5s (measured: 0.84-1.62s of lead trimmed across 7 field
calls), so slicing the outgoing call at the new grant's exact timestamp cut
roughly the last 1.5s of its real speech — calls ending mid-word with ~0s
trailing silence. Do not reintroduce a zero-pad close for tgid_change or
tgid_change_unlogged; if the outgoing and incoming recordings end up
overlapping in the underlying audio because of this pad, that is correct —
the audio genuinely contains both. See _handle_call_log and
_handle_channels for how each path sources the timestamp this gets added to.
"""
return settings.call_tail_pad_seconds
@@ -189,9 +205,16 @@ class MetadataWatcher:
return
# SPLIT: different talkgroup. The new grant's OP25 timestamp is the most
# precise end available for the outgoing segment — the new call's audio
# starts exactly there, so no tail pad.
await self._close_segment(started_at, reason="tgid_change")
# precise CONTROL-CHANNEL end for the outgoing segment, but the buffered
# audio lags control by ~1.5s, so slicing exactly there cut the outgoing
# call's last words. Pad past it and let the recorder's bounded tail wait
# block until that audio has actually been captured.
#
# The incoming call's pre-roll comes from the ring buffer, so the delay
# costs it nothing, and the two slices overlapping in the underlying
# audio is correct — the stream genuinely contains one call's tail and
# then the next call's start.
await self._close_segment(started_at + _tail_pad(), reason="tgid_change")
await self._open_segment(entry, tgid, started_at, now)
async def _handle_channels(self, channels: List[Dict[str, Any]], now: float) -> None:
@@ -236,7 +259,7 @@ class MetadataWatcher:
f"tgid {foreign_active_tgid} active without a call_log entry — "
f"closing segment for tgid {self._current_tgid} (call_log event likely dropped)."
)
await self._close_segment(now, reason="tgid_change_unlogged")
await self._close_segment(now + _tail_pad(), reason="tgid_change_unlogged")
return
if (now - self._last_activity) >= settings.call_idle_timeout:
+85 -20
View File
@@ -5,11 +5,24 @@ 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.
`op25-container/docker-entrypoint.sh` waits (bounded) for that daemon to
actually answer before starting its own app, and the edge-node needs the same
guarantee before launching FFmpeg: FFmpeg with `-f pulse` fails instantly if
nothing is listening, and used to stay dead for the lifetime of the process.
This module is the wait.
HISTORY / WHY THIS CHECKS LIVENESS, NOT FILE EXISTENCE: the `pulse_socket`
named volume survives container recreation, but the daemon process that
created the socket does not. Observed on live hardware: a stale
`/run/pulse/native` socket file and `/run/pulse/pid` from a killed daemon
were still in the volume after `docker compose up -d --build` recreated the
containers. PulseAudio refused to start ("Daemon already running") because of
the stale pid file, so nothing was actually listening on the socket — but the
socket *file* still existed. An earlier version of this module (and of the
op25 entrypoint) only checked `stat.S_ISSOCK` on the path, so it reported
"ready" against a dead daemon, FFmpeg launched anyway, and immediately failed
with "No such process" in a tight restart loop. Readiness here means "a
PulseAudio connection actually succeeds," never "a file exists at this path."
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
@@ -19,7 +32,8 @@ monitor explicitly via settings.pulse_source (default "drb_sink.monitor").
"""
import asyncio
import os
import stat
import shutil
import subprocess
from typing import Optional
from app.config import settings
@@ -28,6 +42,14 @@ from app.internal.logger import logger
DEFAULT_SOCKET_PATH = "/run/pulse/native"
POLL_INTERVAL = 0.5
# Bounded timeout for a single `pactl info` liveness probe. Kept short: this
# runs synchronously on the calling thread (see is_ready()), and callers of
# is_ready() include a sync code path inside the Discord voice bot, so a slow
# probe would stall its event loop. wait_until_ready() runs probes off-thread
# via asyncio.to_thread and can afford this bound comfortably within its own
# much larger PULSE_WAIT_TIMEOUT.
PROBE_TIMEOUT_SECONDS = 1.5
def socket_path() -> str:
"""Resolve the PulseAudio socket path from PULSE_SERVER (`unix:/path` form)."""
@@ -39,38 +61,81 @@ def socket_path() -> str:
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:
def _probe_env(path: str) -> dict:
env = dict(os.environ)
env["PULSE_SERVER"] = f"unix:{path}"
return env
def _daemon_responds() -> bool:
"""
True only when a PulseAudio daemon actually answers on the configured
socket. Shells out to `pactl info` (from `pulseaudio-utils`, installed
alongside `libpulse0` in the edge-node image) rather than re-implementing
the native protocol handshake in Python — this container has no other use
for talking to PulseAudio directly, so a subprocess call is the smallest
correct implementation.
Deliberately does NOT check `os.path.exists`/`stat.S_ISSOCK` first: a
stale socket file from a killed daemon passes that check and always did,
which is the exact defect this function replaces.
"""
path = socket_path()
pactl = shutil.which("pactl")
if pactl is None:
logger.error("pactl not found in PATH — cannot verify PulseAudio liveness.")
return False
try:
result = subprocess.run(
[pactl, "info"],
env=_probe_env(path),
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
timeout=PROBE_TIMEOUT_SECONDS,
)
return result.returncode == 0
except (subprocess.TimeoutExpired, OSError):
return False
def is_ready() -> bool:
"""
True when a PulseAudio daemon is alive and answering right now.
Synchronous and bounded by PROBE_TIMEOUT_SECONDS — used from a sync call
site (discord_radio._play_stream). Prefer `wait_until_ready()` from async
code so the probe doesn't block the event loop.
"""
return _daemon_responds()
async def wait_until_ready(timeout: Optional[float] = None) -> bool:
"""
Block until the PulseAudio socket appears, or `timeout` seconds elapse.
Block until a PulseAudio daemon actually answers, 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).
Bounded on purpose — never hang the caller forever. Returns True once a
live connection succeeds, False on timeout (caller decides whether to
retry). Each probe runs via asyncio.to_thread so the subprocess call never
blocks the event loop.
"""
limit = settings.pulse_wait_timeout if timeout is None else timeout
path = socket_path()
if is_ready():
if await asyncio.to_thread(_daemon_responds):
return True
logger.info(f"Waiting up to {limit:.0f}s for PulseAudio socket at {path}")
logger.info(f"Waiting up to {limit:.0f}s for a live PulseAudio daemon 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.")
if await asyncio.to_thread(_daemon_responds):
logger.info(f"PulseAudio daemon live 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."
f"PulseAudio daemon at {path} not responding after {limit:.0f}s — "
"is the op25 container's daemon actually up? Audio capture will retry."
)
return False
+60
View File
@@ -421,4 +421,64 @@ def test_memory_ceiling_covers_the_longest_allowed_call():
"""The cap must bound RAM without ever being able to truncate a legal call."""
bytes_per_second = 16_000 // 8
assert MAX_RECORDING_BYTES >= MAX_RECORDING_SECONDS * bytes_per_second
# ---------------------------------------------------------------------------
# Capture-exit classification — the two failure modes must be told apart
# instead of both logging the same generic "restarting" line. This is what
# let a wrong PULSE_SOURCE hide behind normal-looking startup retries before.
# ---------------------------------------------------------------------------
def _log_levels(caplog, logger_name="drb-edge-node"):
return [r.levelname for r in caplog.records if r.name == logger_name]
def test_capture_exit_logs_error_when_source_missing(recorder, caplog):
"""FFmpeg's pulse input prints 'No such process' when the daemon is up
but the configured source name does not exist — a real misconfiguration,
not a startup race, so this must stand out as an error naming the source."""
recorder._last_stderr_lines.append(
"[pulse @ 0x...] pa_stream_connect_record failed: No such process"
)
with caplog.at_level("INFO", logger="drb-edge-node"):
recorder._log_capture_exit()
assert "ERROR" in _log_levels(caplog)
error_messages = [r.message for r in caplog.records if r.levelname == "ERROR"]
assert any(settings.pulse_source in m for m in error_messages)
def test_capture_exit_logs_info_when_no_daemon(recorder, caplog):
"""Connection refused means nothing is listening yet — expected during
startup, so it must NOT be logged at the same severity as a real
misconfiguration."""
recorder._last_stderr_lines.append(
"[pulse @ 0x...] pa_context_connect() failed: Connection refused"
)
with caplog.at_level("INFO", logger="drb-edge-node"):
recorder._log_capture_exit()
levels = _log_levels(caplog)
assert "ERROR" not in levels
assert "INFO" in levels
def test_capture_exit_falls_back_to_generic_warning(recorder, caplog):
"""An FFmpeg failure that matches neither known marker keeps the original
generic behavior rather than guessing."""
recorder._last_stderr_lines.append("[pulse @ 0x...] some other unexpected failure")
with caplog.at_level("INFO", logger="drb-edge-node"):
recorder._log_capture_exit()
assert _log_levels(caplog) == ["WARNING"]
def test_capture_exit_with_no_stderr_captured_is_generic_warning(recorder, caplog):
"""No stderr at all (e.g. FFmpeg killed before printing anything) must not
crash the classifier and must fall back to the generic message."""
assert list(recorder._last_stderr_lines) == []
with caplog.at_level("INFO", logger="drb-edge-node"):
recorder._log_capture_exit()
assert _log_levels(caplog) == ["WARNING"]
assert MAX_RECORDING_BYTES <= 8 * 1024 * 1024, "must stay small enough for a Pi"
+50 -8
View File
@@ -233,15 +233,17 @@ async def test_srcaddr_edge_then_idle_timeout_ends_call(watcher, clock):
@pytest.mark.asyncio
async def test_tail_pad_is_configurable_and_defaults_to_one_second(watcher, clock, monkeypatch):
async def test_tail_pad_is_configurable_and_defaults_to_three_seconds(watcher, clock, monkeypatch):
"""
0.5s left only ~0.3s of real trailing margin in field measurement and one
recording ended mid-word, so the default moved to 1.0 — and it has to be a
setting, not a magic number, so it can be tuned per node.
Field measurement showed the grant->speech offset runs ~0.84-1.62s, so a
1.0s pad let short calls' windows close before voice audio even started
(clipping mid-word). The default moved to 3.0 — and it has to be a
setting, not a magic number, so it can be tuned per node without a code
change (and so tests can prove it isn't hardcoded anywhere downstream).
"""
assert settings.call_tail_pad_seconds == 1.0
assert settings.call_tail_pad_seconds == 3.0
monkeypatch.setattr(settings, "call_tail_pad_seconds", 2.5)
monkeypatch.setattr(settings, "call_tail_pad_seconds", 5.0)
await tick(watcher, update(
call_log=[grant(1234, clock.now)],
@@ -251,11 +253,51 @@ async def test_tail_pad_is_configurable_and_defaults_to_one_second(watcher, cloc
edge_time = clock.now
await tick(watcher, update(channels=[channel(tgid=1234, srcaddr=0, hold_tgid=1234)]))
clock.advance(settings.call_idle_timeout + 1.0)
# Advance well past both the idle timeout AND the monkeypatched 5.0s pad so
# the "now" cap in _handle_channels never masks the pad value under test.
clock.advance(settings.call_idle_timeout + 6.0)
await tick(watcher, update(channels=[channel()]))
payload = watcher.on_call_end.call_args[0][0]
assert payload["ended_at_epoch"] == pytest.approx(edge_time + 2.5)
assert payload["ended_at_epoch"] == pytest.approx(edge_time + 5.0)
@pytest.mark.asyncio
async def test_short_call_window_now_covers_delayed_voice_arrival(watcher, clock):
"""
Regression test for the truncation bug: a ~0.97s control-channel call
(grant to srcaddr-drop) previously closed its window at
last_tx_end + 1.0s pad, i.e. ~1.97s after the grant — but field
measurement shows voice audio doesn't start until ~1.5s after the grant
(0.84-1.62s measured), so the old window left as little as ~0.4s of
captured speech and clipped it mid-word.
With the 3.0s default pad, the same short call's window must extend well
past the ~1.5s point where voice actually starts.
"""
call_start = clock.now
await tick(watcher, update(
call_log=[grant(1234, call_start)],
channels=[channel(tgid=1234, srcaddr=555)],
))
# The control-channel call itself is short — under 1 second.
clock.advance(0.97)
edge_time = clock.now
await tick(watcher, update(channels=[channel(tgid=1234, srcaddr=0, hold_tgid=1234)]))
clock.advance(settings.call_idle_timeout + 0.5)
await tick(watcher, update(channels=[channel()]))
payload = watcher.on_call_end.call_args[0][0]
assert payload["end_reason"] == "idle_timeout"
voice_arrival = call_start + 1.5 # measured grant->speech offset, typical case
assert payload["ended_at_epoch"] == pytest.approx(edge_time + settings.call_tail_pad_seconds)
assert payload["ended_at_epoch"] > voice_arrival, (
"recording window must extend past the point voice audio actually arrives, "
"not just past the control-channel call_log timestamps"
)
@pytest.mark.asyncio
+135
View File
@@ -0,0 +1,135 @@
"""
Unit tests for PulseAudio readiness helpers (app.internal.pulse).
The whole point of this module is that readiness means "a PulseAudio
connection actually succeeds," never "a socket file exists at this path"
that was the exact bug reproduced on live hardware: a killed daemon left its
pid file and native socket behind in the shared `pulse_socket` volume, the
old file-existence check reported "ready", and FFmpeg launched against a
dead daemon.
No real PulseAudio daemon or `pactl` binary is required for these tests:
`_daemon_responds` (the one function that actually shells out) is monkeypatched
everywhere except the dedicated subprocess-layer tests, which fake out
`shutil.which`/`subprocess.run` directly so the "stale file, dead daemon" case
is proven at the layer that matters.
"""
import subprocess
from unittest.mock import Mock
from app.internal import pulse
# ---------------------------------------------------------------------------
# socket_path()
# ---------------------------------------------------------------------------
def test_socket_path_defaults_when_pulse_server_unset(monkeypatch):
monkeypatch.delenv("PULSE_SERVER", raising=False)
assert pulse.socket_path() == pulse.DEFAULT_SOCKET_PATH
def test_socket_path_parses_unix_prefixed_pulse_server(monkeypatch):
monkeypatch.setenv("PULSE_SERVER", "unix:/tmp/somewhere/native")
assert pulse.socket_path() == "/tmp/somewhere/native"
def test_socket_path_falls_back_on_malformed_pulse_server(monkeypatch):
monkeypatch.setenv("PULSE_SERVER", "not-a-unix-uri")
assert pulse.socket_path() == pulse.DEFAULT_SOCKET_PATH
# ---------------------------------------------------------------------------
# is_ready() / wait_until_ready() against a monkeypatched probe
# ---------------------------------------------------------------------------
def test_is_ready_true_when_daemon_responds(monkeypatch):
monkeypatch.setattr(pulse, "_daemon_responds", lambda: True)
assert pulse.is_ready() is True
def test_is_ready_false_when_daemon_does_not_respond(monkeypatch):
monkeypatch.setattr(pulse, "_daemon_responds", lambda: False)
assert pulse.is_ready() is False
async def test_wait_until_ready_short_circuits_when_already_live(monkeypatch):
calls = Mock(return_value=True)
monkeypatch.setattr(pulse, "_daemon_responds", calls)
assert await pulse.wait_until_ready(timeout=5) is True
assert calls.call_count == 1
async def test_wait_until_ready_polls_until_daemon_comes_up(monkeypatch):
monkeypatch.setattr(pulse, "POLL_INTERVAL", 0.01)
responses = iter([False, False, True])
monkeypatch.setattr(pulse, "_daemon_responds", lambda: next(responses))
assert await pulse.wait_until_ready(timeout=5) is True
async def test_wait_until_ready_times_out_when_daemon_never_responds(monkeypatch):
monkeypatch.setattr(pulse, "POLL_INTERVAL", 0.01)
monkeypatch.setattr(pulse, "_daemon_responds", lambda: False)
assert await pulse.wait_until_ready(timeout=0.05) is False
async def test_wait_until_ready_uses_settings_default_when_timeout_omitted(monkeypatch):
monkeypatch.setattr(pulse.settings, "pulse_wait_timeout", 0.05)
monkeypatch.setattr(pulse, "POLL_INTERVAL", 0.01)
monkeypatch.setattr(pulse, "_daemon_responds", lambda: False)
assert await pulse.wait_until_ready() is False
# ---------------------------------------------------------------------------
# _daemon_responds() at the subprocess layer — proves a stale FILE is not
# enough, which is the actual regression this module fixes.
# ---------------------------------------------------------------------------
def test_daemon_responds_false_when_pactl_missing(monkeypatch):
monkeypatch.setattr(pulse.shutil, "which", lambda name: None)
assert pulse._daemon_responds() is False
def test_daemon_responds_false_on_probe_timeout(monkeypatch, tmp_path):
monkeypatch.setattr(pulse.shutil, "which", lambda name: "/usr/bin/pactl")
def fake_run(*args, **kwargs):
raise subprocess.TimeoutExpired(cmd="pactl", timeout=pulse.PROBE_TIMEOUT_SECONDS)
monkeypatch.setattr(pulse.subprocess, "run", fake_run)
assert pulse._daemon_responds() is False
def test_daemon_responds_false_when_stale_socket_file_exists_but_daemon_dead(monkeypatch, tmp_path):
"""
The regression, reproduced at the layer that matters: a plain FILE sits
at the socket path (exactly what a killed daemon leaves behind), but
`pactl info` against it fails (nonzero exit — connection refused). This
must NOT be treated as ready.
"""
stale_socket = tmp_path / "native"
stale_socket.write_bytes(b"") # a stale file, not a live socket
monkeypatch.setenv("PULSE_SERVER", f"unix:{stale_socket}")
monkeypatch.setattr(pulse.shutil, "which", lambda name: "/usr/bin/pactl")
monkeypatch.setattr(
pulse.subprocess, "run",
lambda *a, **k: subprocess.CompletedProcess(args=a, returncode=1),
)
assert stale_socket.exists() # sanity: the old file-existence check would pass
assert pulse._daemon_responds() is False
def test_daemon_responds_true_when_pactl_succeeds(monkeypatch, tmp_path):
live_socket = tmp_path / "native"
live_socket.write_bytes(b"")
monkeypatch.setenv("PULSE_SERVER", f"unix:{live_socket}")
monkeypatch.setattr(pulse.shutil, "which", lambda name: "/usr/bin/pactl")
monkeypatch.setattr(
pulse.subprocess, "run",
lambda *a, **k: subprocess.CompletedProcess(args=a, returncode=0),
)
assert pulse._daemon_responds() is True
+65 -32
View File
@@ -1,32 +1,65 @@
#!/bin/bash
# --- Start PulseAudio Daemon ---
# -n: skip default config (load modules inline — avoids system.pa parsing issues)
# --system: run as system-wide daemon
# --log-target=stderr: makes errors visible in Docker logs
# &: background so this script continues; output still captured by Docker
echo "Starting PulseAudio daemon..."
mkdir -p /run/pulse
chmod 777 /run/pulse
pulseaudio --exit-idle-time=-1 -n --system \
--load="module-native-protocol-unix socket=/run/pulse/native auth-anonymous=1" \
--load="module-null-sink sink_name=drb_sink sink_properties=device.description=DRB-Sink" \
--log-target=stderr &
# Wait for the socket to actually exist before continuing
echo "Waiting for PulseAudio socket..."
for i in $(seq 1 20); do
if [ -S /run/pulse/native ]; then
echo "PulseAudio socket ready."
break
fi
sleep 0.5
done
if [ ! -S /run/pulse/native ]; then
echo "WARNING: PulseAudio socket not found after 10s — edge-node audio will fail."
fi
ls -la /run/pulse/
# --- Execute the main command (uvicorn) ---
echo "Starting FastAPI application..."
exec "$@"
#!/bin/bash
PULSE_SOCKET=/run/pulse/native
PULSE_PIDFILE=/run/pulse/pid
mkdir -p /run/pulse
chmod 777 /run/pulse
# Returns 0 (true) only when a PulseAudio daemon actually answers on
# $PULSE_SOCKET. A socket/pid FILE existing proves nothing by itself — that
# is exactly the bug this script works around (see stale-state check below).
pulse_daemon_alive() {
PULSE_SERVER="unix:${PULSE_SOCKET}" timeout 2 pactl info >/dev/null 2>&1
}
# --- Clear stale PulseAudio state left behind by a killed daemon ---
# The `pulse_socket` named volume survives container recreation, but the
# PulseAudio process that owned it does not. If the previous container was
# recreated (not gracefully stopped), its pid file and native socket are
# still sitting in the volume; pulseaudio's pid.c sees the pid file and
# refuses to start ("Daemon already running") even though nothing is
# listening. Only remove these when nothing actually answers on the socket —
# never delete a socket a live daemon is using.
if [ -S "$PULSE_SOCKET" ] || [ -f "$PULSE_PIDFILE" ]; then
if pulse_daemon_alive; then
echo "PulseAudio daemon already alive and responding at ${PULSE_SOCKET} — leaving state as-is."
else
echo "STALE STATE: found ${PULSE_PIDFILE} / ${PULSE_SOCKET} from a previous container, but no daemon answers — clearing before start."
rm -f "$PULSE_SOCKET" "$PULSE_PIDFILE"
fi
fi
# --- Start PulseAudio Daemon ---
# -n: skip default config (load modules inline — avoids system.pa parsing issues)
# --system: run as system-wide daemon
# --log-target=stderr: makes errors visible in Docker logs
# &: background so this script continues; output still captured by Docker
echo "Starting PulseAudio daemon..."
pulseaudio --exit-idle-time=-1 -n --system \
--load="module-native-protocol-unix socket=${PULSE_SOCKET} auth-anonymous=1" \
--load="module-null-sink sink_name=drb_sink sink_properties=device.description=DRB-Sink" \
--log-target=stderr &
# Wait for the daemon to actually answer — NOT just for the socket file to
# exist. A stale socket file from a killed daemon exists but nothing is
# listening on it; a file-existence check reports "ready" against a dead
# daemon, which is exactly how this class of bug slipped through before.
echo "Waiting for PulseAudio to become live..."
PULSE_LIVE=0
for i in $(seq 1 20); do
if pulse_daemon_alive; then
echo "PulseAudio daemon is live (pactl info succeeded)."
PULSE_LIVE=1
break
fi
sleep 0.5
done
if [ "$PULSE_LIVE" -ne 1 ]; then
echo "WARNING: PulseAudio daemon not responding after 10s — edge-node audio will fail until it recovers."
fi
ls -la /run/pulse/
# --- Execute the main command (uvicorn) ---
echo "Starting FastAPI application..."
exec "$@"