From f1de157d69944f34d89921ddd46a46bc1a6f38ab Mon Sep 17 00:00:00 2001 From: Logan Cusano Date: Tue, 4 Aug 2026 23:53:27 -0400 Subject: [PATCH] Pad the tgid_change closes so a talkgroup switch stops clipping the tail 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 --- drb-edge-node/app/internal/call_recorder.py | 6 +++- .../app/internal/metadata_watcher.py | 32 +++++++++++++++---- 2 files changed, 30 insertions(+), 8 deletions(-) diff --git a/drb-edge-node/app/internal/call_recorder.py b/drb-edge-node/app/internal/call_recorder.py index 3e01a65..2ac660e 100644 --- a/drb-edge-node/app/internal/call_recorder.py +++ b/drb-edge-node/app/internal/call_recorder.py @@ -89,7 +89,11 @@ 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. diff --git a/drb-edge-node/app/internal/metadata_watcher.py b/drb-edge-node/app/internal/metadata_watcher.py index c04a86e..eee282d 100644 --- a/drb-edge-node/app/internal/metadata_watcher.py +++ b/drb-edge-node/app/internal/metadata_watcher.py @@ -62,9 +62,20 @@ def _tail_pad() -> float: why the default moved 1.0 → 3.0 (short calls' recording window was closing before the ~1.5s grant→speech offset let voice audio even start). - Only the idle-timeout close path below uses this pad — the tgid_change and - tgid_change_unlogged paths close at an exact, already-known boundary (the - new grant's timestamp, or the same poll tick) and intentionally add none. + 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 @@ -194,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: @@ -241,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: