Drive call boundaries from audio, use the console only for the label
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:
@@ -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.71–2.45 s
|
||||
of dead air at the head of every single file, and the tail pad adds its own
|
||||
~0.3–1.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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user