Drive call boundaries from audio, use the console only for the label
CI / lint (push) Failing after 5s
CI / test (push) Successful in 36s

The control channel was wrong in both directions. Grants fire 0.84-1.62s
before anyone speaks, and srcaddr can drop to 0 while someone is still
talking - one recording came back "-1.61s lead, -0.00s tail", the trim
finding nothing to remove because the window had closed on live speech.
Confirmed by ear: the cut lands at a word boundary on an unfinished word.

Audio is ground truth for WHEN. The console remains the only source of
WHO, so it still supplies talkgroup, alias and rid.

  START  voice onset in the captured audio, with a 0.25s pre-roll that
         now covers only chunk quantisation and threshold ramp-up rather
         than a variable control-channel offset.
  STOP   call_silence_timeout seconds of silence heard in the audio.
  LABEL  resolved AT CLOSE from a bounded rolling history of console
         observations overlapping the window, +4s/-2s, because there is
         no guaranteed ordering between a grant and its audio.
  SPLIT  a console talkgroup change still forces a cut, since two calls
         with no silence between them would otherwise merge into one.

Capture now emits raw PCM instead of MP3. Silence detection becomes
integer arithmetic per chunk with no decode, trimming becomes a byte
offset slice rather than a second ffmpeg pass, and MP3 encoding happens
exactly once at save - uploads are no longer double-encoded.

Audio with no talkgroup anywhere in its window is discarded rather than
uploaded: an untagged call silently poisons incident correlation, which
is worse than losing the audio. Logged at ERROR and counted on
/api/status.

When capture produces no audio at all the old console state machine
still runs, so a node with a broken audio path keeps reporting radio
activity. That is now the only consumer of call_idle_timeout.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Logan Cusano
2026-08-06 18:19:45 -04:00
parent 085fcdf1a1
commit d6dfe5a293
12 changed files with 2472 additions and 712 deletions
+136 -194
View File
@@ -1,270 +1,212 @@
"""
Conservative leading/trailing silence removal for finished call recordings.
Leading/trailing silence removal, as a slice of raw PCM.
WHY: P25 grants the channel, radios tune, and only then does a human start
talking. Measured on six real recordings from a live node, that costs 1.712.45 s
of dead air at the head of every single file, and the tail pad adds its own
~0.31.0 s. Roughly 63% of every uploaded MP3 was silence. That is not just
wasted Whisper spend: silence is a well-documented trigger for Whisper
hallucinating text that was never spoken, and a hallucinated sentence poisons
entity extraction and then incident correlation downstream.
talking; the recorder also deliberately over-captures at the tail (it closes a
call only after N seconds of silence have actually been HEARD). Both ends
therefore carry dead air. That is not just wasted Whisper spend: silence is a
well-documented trigger for Whisper hallucinating text that was never spoken,
and a hallucinated sentence poisons entity extraction and then incident
correlation downstream.
WHY IT IS SAFE: only the head and tail are touched, never the middle, and a
guard margin is kept around the detected speech so no syllable can be clipped.
If detection says the whole file is silent we do NOT emit a zero-length file —
the caller is told and decides (see call_recorder: it skips the upload and logs).
If detection says the whole buffer is silent we do NOT emit a zero-length
recording — the caller is told and decides (see call_recorder: it skips the
upload and logs).
TIMING: trimming changes the audio's duration relative to the call's wall-clock
start/end, so every trim reports exactly how much was removed from each end.
Callers must carry those offsets forward — `started_at`/`ended_at` keep meaning
the CALL's bounds, and the trimmed audio's own bounds are reported separately.
Implementation is two FFmpeg passes (detect, then cut). FFmpeg is already a hard
dependency of this container and is already running the capture, so this adds no
new moving parts. `silenceremove` in one pass was rejected on purpose: it gives
no way to learn how much it removed, which would make the timing metadata above
impossible to produce.
HISTORY — THIS USED TO BE TWO FFMPEG PASSES. Detection was `silencedetect`
parsed out of FFmpeg's stderr, and the cut was a second FFmpeg re-encode. Both
are gone: the recorder now buffers PCM, so detection is arithmetic over the
samples and the cut is a byte-offset slice. Consequences worth keeping in mind:
* The recording is encoded to MP3 exactly ONCE, after this runs, instead of
being captured as MP3 and then re-encoded. One less generation of lossy
encoding on every upload, and one less subprocess per call.
* The threshold is now RMS over a short window (see pcm.rms_dbfs), where
FFmpeg's silencedetect compared |sample| per sample. Same units (dBFS),
slightly different meaning — do not port an old threshold across without
re-reading the field logs.
* There is no "is it worth re-encoding" minimum any more. A slice is free, so
even a 0.05 s trim is applied.
"""
import asyncio
import re
from dataclasses import dataclass
from pathlib import Path
from typing import List, Optional, Tuple
from typing import Optional, Tuple
from app.config import settings
from app.internal import pcm
from app.internal.logger import logger
# Minimum run of quiet before FFmpeg calls it a silence region. Below ~0.3 s this
# starts firing on the natural pauses between words, which is not what we want —
# we only care about the big block of dead air at each end.
MIN_SILENCE_SECONDS = 0.3
# Window the head/tail scan works in. 20 ms is short enough that the guard
# margin below dwarfs the quantisation error, and long enough that RMS means
# something.
ANALYSIS_WINDOW_SECONDS = 0.02
# A silence region only counts as "leading" if it begins essentially at the file
# head. One chunk of slop.
HEAD_EPSILON_SECONDS = 0.15
# ...and only as "trailing" if it reaches the end of the audio. Do NOT assume a
# missing `silence_end` marks that case: FFmpeg 6.x flushes a closing
# `silence_end` at EOF, so an all-silence file looks exactly like a file with one
# closed silence region. Verified against ffmpeg 6.1.1 — the reported end lands
# ~0.03 s short of the (offset-corrected) duration, hence this epsilon. The
# residual risk is clipping <=0.1 s of audio that follows a >=0.3 s gap right at
# EOF, which the guard margin below more than covers.
TAIL_EPSILON_SECONDS = 0.10
# Don't bother re-encoding to reclaim less than this — a re-encode costs a CPU
# spike on a Pi and a generation of MP3 quality, which is a bad trade for
# a fraction of a second.
MIN_TRIM_SECONDS = 0.20
# Bounded so a wedged FFmpeg can never stall the upload path.
FFMPEG_TIMEOUT_SECONDS = 30.0
_SILENCE_START_RE = re.compile(r"silence_start:\s*(-?[0-9.]+)")
_SILENCE_END_RE = re.compile(r"silence_end:\s*(-?[0-9.]+)")
_DURATION_RE = re.compile(r"Duration:\s*(\d+):(\d\d):(\d\d\.\d+)")
_START_RE = re.compile(r"Duration:[^\n]*?start:\s*(-?[0-9.]+)")
# How far in from each end the scan is willing to look before giving up.
#
# Bounds the only unbounded cost in this module: the per-sample RMS loop. A
# normal recording resolves within a window or two at the head (the recorder
# starts on voice onset) and within the silence run at the tail, so this cap is
# never reached in practice. If it IS reached, we leave the audio untrimmed and
# say so — shipping an untrimmed recording is always better than shipping none.
MAX_SCAN_SECONDS = 30.0
@dataclass(frozen=True)
class TrimResult:
"""Outcome of a trim attempt. `lead`/`tail` are seconds actually removed."""
path: Optional[Path]
lead: float = 0.0
tail: float = 0.0
duration_before: float = 0.0
duration_after: float = 0.0
all_silence: bool = False
applied: bool = False
# True when the scan hit MAX_SCAN_SECONDS without finding speech, so
# `all_silence` could not be determined and nothing was trimmed.
scan_truncated: bool = False
@property
def trimmed_seconds(self) -> float:
return self.lead + self.tail
async def _run(cmd: List[str]) -> Tuple[int, str]:
"""Run FFmpeg and return (returncode, stderr). FFmpeg reports on stderr."""
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.PIPE,
)
try:
_, stderr = await asyncio.wait_for(proc.communicate(), timeout=FFMPEG_TIMEOUT_SECONDS)
except asyncio.TimeoutError:
try:
proc.kill()
except Exception:
pass
raise
return proc.returncode or 0, stderr.decode(errors="replace")
def _window_bytes() -> int:
return max(pcm.FRAME_BYTES, pcm.byte_offset(ANALYSIS_WINDOW_SECONDS))
def _parse_duration(stderr: str) -> Optional[float]:
def first_signal_offset(
audio: bytes,
threshold_db: float,
limit_seconds: float = MAX_SCAN_SECONDS,
) -> Optional[int]:
"""
Length of the decodable audio, in silencedetect's coordinates.
Byte offset of the first window carrying signal, scanning forward.
MP3 carries encoder delay/padding, which FFmpeg reports as a container
`start:` offset — the container duration is that much longer than the audio
silencedetect actually timestamps. Subtracting it is what lets
TAIL_EPSILON_SECONDS stay tight enough to be safe.
None means "no signal found" — either the buffer really is all silence or
the scan hit `limit_seconds` first; the caller distinguishes the two by
comparing the scanned span against the buffer length.
"""
match = _DURATION_RE.search(stderr)
if not match:
return None
hours, minutes, seconds = match.groups()
duration = int(hours) * 3600 + int(minutes) * 60 + float(seconds)
start = _START_RE.search(stderr)
if start:
offset = float(start.group(1))
if 0.0 <= offset < duration:
duration -= offset
return duration
window = _window_bytes()
limit = min(len(audio), pcm.byte_offset(limit_seconds) or len(audio))
offset = 0
while offset < limit:
chunk = audio[offset:offset + window]
if not pcm.is_silent(chunk, threshold_db):
return offset
offset += window
return None
def _parse_silences(stderr: str) -> List[Tuple[float, Optional[float]]]:
def last_signal_offset(
audio: bytes,
threshold_db: float,
limit_seconds: float = MAX_SCAN_SECONDS,
) -> Optional[int]:
"""
Extract silence regions as (start, end) with end=None when it runs to EOF.
Byte offset of the END of the last window carrying signal, scanning back.
FFmpeg emits `silence_start:` and a later `silence_end:` per region, and
simply never emits the closing line for a region that reaches EOF.
Returns the offset one past the last signal-bearing window, so it can be
used directly as a slice bound.
"""
regions: List[Tuple[float, Optional[float]]] = []
pending: Optional[float] = None
for line in stderr.splitlines():
if "silencedetect" not in line:
continue
start = _SILENCE_START_RE.search(line)
if start:
pending = float(start.group(1))
continue
end = _SILENCE_END_RE.search(line)
if end and pending is not None:
regions.append((pending, float(end.group(1))))
pending = None
if pending is not None:
regions.append((pending, None))
return regions
window = _window_bytes()
total = pcm.align(len(audio))
floor = max(0, total - (pcm.byte_offset(limit_seconds) or total))
offset = total
while offset > floor:
start = max(floor, offset - window)
if not pcm.is_silent(audio[start:offset], threshold_db):
return offset
offset = start
return None
def speech_bounds(
regions: List[Tuple[float, Optional[float]]],
duration: float,
guard: float,
) -> Tuple[float, float, bool]:
def keep_window(
first_signal: Optional[int],
last_signal: Optional[int],
total_bytes: int,
guard_bytes: int,
) -> Tuple[int, int]:
"""
Turn detected silence regions into the [start, end] window to keep.
Turn detected signal bounds into the byte range to keep.
Pure and side-effect free so the decision logic is unit-testable without
FFmpeg. Returns (start, end, all_silence).
Pure and side-effect free so the decision that can destroy a transmission
stays unit-testable without any audio. Offsets are sample-aligned and
clamped to the buffer.
"""
speech_start = 0.0
speech_end = duration
if regions:
head_start, head_end = regions[0]
if head_start <= HEAD_EPSILON_SECONDS and head_end is not None:
speech_start = head_end
tail_start, tail_end = regions[-1]
# `None` = pre-6.x FFmpeg, which simply stopped reporting at EOF.
reaches_eof = tail_end is None or (duration - tail_end) <= TAIL_EPSILON_SECONDS
if reaches_eof:
speech_end = min(speech_end, tail_start)
if speech_end <= speech_start:
# Detection says there is no speech anywhere in the file.
return 0.0, duration, True
# Guard margin: never trim right up against the first/last syllable.
keep_start = max(0.0, speech_start - guard)
keep_end = min(duration, speech_end + guard)
return keep_start, keep_end, False
total = pcm.align(total_bytes)
start = 0 if first_signal is None else max(0, first_signal - guard_bytes)
end = total if last_signal is None else min(total, last_signal + guard_bytes)
start = pcm.align(start)
end = pcm.align(end)
if end <= start:
return 0, total
return start, end
async def trim_silence(
path: Path,
sample_rate: str,
bitrate: str,
def trim_pcm(
audio: bytes,
threshold_db: Optional[float] = None,
guard: Optional[float] = None,
) -> TrimResult:
) -> Tuple[bytes, TrimResult]:
"""
Trim leading/trailing silence in place, preserving the original on failure.
Return (kept_audio, result). Never raises and never returns empty audio.
Never raises: any problem degrades to "leave the file exactly as it was",
because shipping an untrimmed recording is far better than shipping none.
An all-silence buffer is returned UNCHANGED with `all_silence=True`: the
caller decides what to do with a recording that contains no speech at all —
that is itself a signal (squelch misconfigured, wrong sink, dead audio
path), not something to silently truncate to nothing.
"""
threshold = settings.trim_silence_threshold_db if threshold_db is None else threshold_db
margin = settings.trim_silence_guard_seconds if guard is None else guard
try:
_, stderr = await _run([
"ffmpeg", "-hide_banner", "-nostdin", "-nostats",
"-i", str(path),
"-af", f"silencedetect=noise={threshold}dB:d={MIN_SILENCE_SECONDS}",
"-f", "null", "-",
])
except Exception as e:
logger.warning(f"Silence detection failed for {path.name} ({e}) — uploading untrimmed.")
return TrimResult(path=path)
total = pcm.align(len(audio))
duration = pcm.seconds(total)
if total <= 0:
return audio, TrimResult()
duration = _parse_duration(stderr)
if duration is None or duration <= 0:
logger.warning(f"Could not determine duration of {path.name} — uploading untrimmed.")
return TrimResult(path=path)
regions = _parse_silences(stderr)
keep_start, keep_end, all_silence = speech_bounds(regions, duration, margin)
if all_silence:
# Deliberately NOT trimmed to nothing. The caller decides what to do with
# a recording that contains no speech at all — that is itself a signal
# (squelch misconfigured, wrong sink, dead audio path).
first = first_signal_offset(audio, threshold)
if first is None:
scanned = min(total, pcm.byte_offset(MAX_SCAN_SECONDS) or total)
if scanned < total:
# Could not prove it is all silence; refuse to guess.
logger.warning(
f"Silence scan gave up after {MAX_SCAN_SECONDS:.0f}s without finding speech in a "
f"{duration:.1f}s recording — leaving it untrimmed."
)
return audio, TrimResult(
duration_before=duration, duration_after=duration, scan_truncated=True
)
logger.warning(
f"{path.name} is entirely silence ({duration:.2f}s, threshold {threshold}dB) — "
f"Recording is entirely silence ({duration:.2f}s, threshold {threshold:.1f}dBFS RMS) — "
"no speech detected."
)
return TrimResult(path=path, duration_before=duration, duration_after=duration, all_silence=True)
return audio, TrimResult(duration_before=duration, duration_after=duration, all_silence=True)
lead = keep_start
tail = duration - keep_end
if (lead + tail) < MIN_TRIM_SECONDS:
return TrimResult(path=path, duration_before=duration, duration_after=duration)
last = last_signal_offset(audio, threshold)
guard_bytes = pcm.byte_offset(margin)
keep_start, keep_end = keep_window(first, last, total, guard_bytes)
trimmed = path.with_name(f"{path.stem}_trimmed{path.suffix}")
try:
code, err = await _run([
"ffmpeg", "-hide_banner", "-nostdin", "-nostats",
"-loglevel", "warning", "-y",
"-ss", f"{keep_start:.3f}",
"-i", str(path),
"-t", f"{keep_end - keep_start:.3f}",
"-ac", "1", "-ar", sample_rate, "-b:a", bitrate,
"-f", "mp3", str(trimmed),
])
except Exception as e:
logger.warning(f"Silence trim failed for {path.name} ({e}) — uploading untrimmed.")
trimmed.unlink(missing_ok=True)
return TrimResult(path=path, duration_before=duration, duration_after=duration)
lead = pcm.seconds(keep_start)
tail = pcm.seconds(total - keep_end)
if keep_start <= 0 and keep_end >= total:
return audio[:total], TrimResult(duration_before=duration, duration_after=duration)
if code != 0 or not trimmed.exists() or trimmed.stat().st_size == 0:
logger.warning(f"Silence trim produced no output for {path.name} ({err.strip()}) — uploading untrimmed.")
trimmed.unlink(missing_ok=True)
return TrimResult(path=path, duration_before=duration, duration_after=duration)
trimmed.replace(path)
kept = audio[keep_start:keep_end]
after = pcm.seconds(len(kept))
logger.info(
f"Trimmed {path.name}: -{lead:.2f}s lead, -{tail:.2f}s tail "
f"({duration:.2f}s {keep_end - keep_start:.2f}s)"
f"Trimmed recording: -{lead:.2f}s lead, -{tail:.2f}s tail "
f"({duration:.2f}s -> {after:.2f}s, threshold {threshold:.1f}dBFS RMS)"
)
return TrimResult(
path=path,
return kept, TrimResult(
lead=lead,
tail=tail,
duration_before=duration,
duration_after=keep_end - keep_start,
duration_after=after,
applied=True,
)