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,
)
+284 -114
View File
@@ -1,16 +1,26 @@
"""
Continuous PulseAudio capture: a ring buffer for PRE-ROLL, a per-call
accumulator for the call itself.
accumulator for the call itself, and the voice-activity signal that decides
where calls begin and end.
A persistent capture process runs for the lifetime of the node. Spawning FFmpeg
per call used to lose the first 1-2 s to process startup, which meant short
transmissions produced empty files, so capture never stops.
RAW PCM, NOT MP3 — this is the change everything else hangs off. FFmpeg is
asked for s16le/22050/mono on stdout instead of an MP3 stream, so:
* silence detection is integer arithmetic over each chunk as it arrives, with
no decode, which is what makes AUDIO-DRIVEN call boundaries possible;
* trimming is a byte-offset slice, not a second FFmpeg pass;
* MP3 encoding happens exactly ONCE, at save time, so uploads are no longer
double-encoded.
TWO BUFFERS, TWO JOBS — this split is load-bearing:
RING BUFFER holds the last RING_BUFFER_SECONDS of audio at all times. Its
only job is PRE-ROLL: however late we notice a grant, we can
still seek back to OP25's exact timestamp. It is sized for
only job is PRE-ROLL: however late the segmenter notices voice
onset, we can still seek back before it. It is sized for
detection latency, nothing else.
ACCUMULATOR opened by start_recording(), fed by every subsequent chunk, and
@@ -20,6 +30,14 @@ TWO BUFFERS, TWO JOBS — this split is load-bearing:
which silently clamped the front of any call longer than
RING_BUFFER_SECONDS.
VOICE ACTIVITY is tracked CONTINUOUSLY, not only while recording, because the
segmenter starts a call from audio onset. `_last_voice_epoch` and
`_voice_onset_epoch` are the whole interface: metadata_watcher polls them via
audio_activity() and owns every decision about segment boundaries. This module
deliberately does not open or close calls by itself — attribution (which
talkgroup this audio belongs to) lives in the watcher, and audio alone cannot
answer it.
Why not Icecast: it lags ~1 s at connect and drifts progressively to 100 s+, so
slice timestamps and audio content diverge without bound. Icecast stays in the
stack for frontend/mobile live listening; it is not an accuracy path.
@@ -31,6 +49,12 @@ the same clock and the pre-roll arithmetic below is a direct subtraction with no
offset mapping. (A wall-clock STEP — e.g. a large NTP correction — would corrupt
at most the calls in flight at that instant; the buffer self-heals within
RING_BUFFER_SECONDS.)
NOTE on what a chunk timestamp means: it is the ARRIVAL time of that audio at
this process, which lags the moment the words were spoken by the PulseAudio →
FFmpeg → pipe latency. Every timestamp this module produces is in that same
arrival clock, so differences between them are exact; only comparisons against
OP25's control-channel timestamps carry the lag, and those are padded for it.
"""
import asyncio
import time
@@ -42,57 +66,72 @@ from typing import List, Optional, Tuple
import httpx
from app.config import settings
from app.internal import audio_trim, credentials, pulse
from app.internal import audio_trim, credentials, pcm, pulse
from app.internal.logger import logger
# Safety cap on a single recording; mirrors MAX_SEGMENT_SECONDS in metadata_watcher.
MAX_RECORDING_SECONDS = 600
# Audio included ahead of OP25's call_log timestamp. The grant is logged when the
# channel is granted, so the first syllable can land marginally before it.
# Audio included ahead of the detected voice onset.
#
# Kept small on purpose: measurement on a live node shows 1.712.45 s of real
# grant→speech delay on every call, so there is no clipping risk at the head and
# a larger pre-roll would only add dead air.
# Under audio-driven segmentation this is no longer covering a variable
# control-channel offset — it covers exactly two things: the analysis chunk
# quantisation (~46 ms) and the possibility that the first syllable ramps up
# through the silence threshold rather than crossing it instantly. 0.25 s is
# generous for both, and anything it drags in that is genuinely silence gets
# trimmed off again before upload.
PRE_ROLL_SECONDS = 0.25
# Rolling history kept for PRE-ROLL ONLY. Budget for the worst realistic
# detection latency: 0.5 s poll interval + ~0.2 s http_server blocking floor +
# up to 3 s httpx timeout on a stalled poll + callback work ≈ 4 s from grant to
# start_recording(). 30 s is ~7x that margin, and at 16 kbps costs only ~60 KB of
# RAM. This value does NOT bound call length — the accumulator does.
# detection latency: the segmenter polls every 0.5 s and can stall for up to a
# 3 s httpx timeout on a bad OP25 poll, so ~4 s from onset to start_recording().
# 30 s is ~7x that margin. At 44.1 KB/s of PCM it costs ~1.3 MB of RAM. This
# value does NOT bound call length — the accumulator does.
RING_BUFFER_SECONDS = 30
# ~128 ms of audio per chunk at 16 kbps. Chunk size IS the timestamp resolution of
# the ring buffer, so it must stay well under PRE_ROLL_SECONDS — the old 4096-byte
# reads were ~2 s per chunk, which made sub-second slicing meaningless.
READ_CHUNK_BYTES = 256
# ~46 ms of audio per chunk. Chunk size is BOTH the timestamp resolution of the
# ring buffer AND the window silence detection runs over, so it has to stay well
# under PRE_ROLL_SECONDS and well under the shortest utterance we care about.
READ_CHUNK_BYTES = 2048
# Encoder settings, matched on purpose to what Liquidsoap already pushes to
# Icecast — %mp3(bitrate=16, samplerate=22050, stereo=false) — so the C2 /upload
# endpoint keeps receiving exactly the kind of MP3 it has always received
# (multipart "audio/mpeg", stored to GCS as .mp3, then fed to Whisper).
# Change both of these together if you ever want higher-fidelity uploads.
# Encoder settings for the single encode at save time, matched on purpose to
# what Liquidsoap already pushes to Icecast — %mp3(bitrate=16, samplerate=22050,
# stereo=false) — so the C2 /upload endpoint keeps receiving exactly the kind of
# MP3 it has always received (multipart "audio/mpeg", stored to GCS as .mp3,
# then fed to Whisper). MP3_SAMPLE_RATE MUST equal pcm.SAMPLE_RATE: the encode
# is a straight pass with no resampling.
MP3_BITRATE = "16k"
MP3_SAMPLE_RATE = "22050"
MP3_SAMPLE_RATE = str(pcm.SAMPLE_RATE)
# Hard memory ceiling for one call's accumulator. 16 kbps is 2 KB/s, so 600 s of
# call is ~1.2 MB; 4x that is the ceiling, which both leaves room for encoder
# overshoot and guarantees a runaway call can never eat a Pi's RAM.
_MP3_BYTES_PER_SECOND = 16_000 // 8
MAX_RECORDING_BYTES = MAX_RECORDING_SECONDS * _MP3_BYTES_PER_SECOND * 4
# Bounded so a wedged encoder can never stall the upload path.
ENCODE_TIMEOUT_SECONDS = 60.0
# Hard memory ceiling for one call's accumulator.
#
# PCM costs 44.1 KB/s where the old MP3 buffer cost 2 KB/s, so this had to be
# re-derived rather than carried over. 600 s (the time cap) of PCM is 26.5 MB;
# 32 MiB is ~761 s, which guarantees the TIME cap always bites first and a legal
# call is never truncated by the byte cap. Peak resident audio is therefore
# ~33.5 MB for the accumulator plus ~1.3 MB for the ring buffer.
#
# Rejected alternatives, for the record: spilling to disk (SD-card wear on a Pi,
# and I/O in the close path); encoding incrementally into MP3 as chunks arrive
# (puts a subprocess back in the hot path and makes the sample-accurate post-hoc
# trim impossible); a lower capture sample rate (changes what Whisper receives).
MAX_RECORDING_BYTES = 32 * 1024 * 1024
# How long stop_recording() will wait for captured audio to actually reach the
# call's end timestamp. PulseAudio → FFmpeg → MP3 encoder → muxer → our pipe read
# is a pipeline with latency, so at the instant a call ends the newest buffered
# chunk is typically a few hundred ms OLDER than the end epoch. Slicing
# 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.
# call's end timestamp. PulseAudio → FFmpeg → our pipe read is a pipeline with
# latency, so at the instant a CONTROL-CHANNEL derived end is computed the
# newest buffered chunk is typically a few hundred ms OLDER than that epoch.
# Slicing immediately therefore cuts the tail short — which costs the last word
# of the transmission, usually the disposition or the address.
#
# 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.
# An audio-driven close never needs this (its end epoch is derived from audio
# that is already buffered, by construction), but a tgid_change close still
# pads past a control-channel timestamp that is ~now, so the wait must exceed
# settings.call_tail_pad_seconds (default 3.0) or it would warn on every
# talkgroup switch. Bounded so a dead capture can never hang the upload path.
TAIL_WAIT_TIMEOUT_SECONDS = 4.0
TAIL_WAIT_POLL_SECONDS = 0.05
@@ -114,12 +153,38 @@ _SOURCE_MISSING_MARKERS = ("no such process", "no such device")
_NO_DAEMON_MARKERS = ("connection refused", "connection failure")
@dataclass(frozen=True)
class AudioActivity:
"""
What the capture stream is doing right now, as raw facts.
Deliberately carries no decision — no "should a call be open" boolean —
because every threshold comparison belongs to metadata_watcher, which owns
the segment state machine and (in tests) an injectable clock. This is a
snapshot of observations, nothing more.
`voice_onset_epoch` is the arrival timestamp of the first chunk of the most
recent run of voice. It is NOT cleared when that run ends, so a caller must
check `last_voice_epoch` against its own clock before treating the run as
live. That is intentional: the segmenter needs the onset of the run it just
finished recording in order to avoid re-opening on the same run.
"""
capturing: bool
recording: bool
last_voice_epoch: Optional[float] = None
voice_onset_epoch: Optional[float] = None
# Convenience for /api/status only; the segmenter recomputes this against
# its own clock.
silence_seconds: float = 0.0
@dataclass
class _ActiveRecording:
"""Audio accumulating for the call currently being recorded."""
call_id: str
call_start: float # OP25 grant epoch
call_start: float # detected voice onset (or a caller-supplied epoch)
slice_start: float # call_start - PRE_ROLL_SECONDS
chunks: List[Tuple[float, bytes]] = field(default_factory=list)
total_bytes: int = 0
@@ -140,7 +205,7 @@ class Recording:
wall_clock_of(audio_offset_t) == audio_start_epoch + t
Bounds are accurate to ±one capture chunk (~128 ms).
Bounds are accurate to ±one capture chunk (~46 ms).
"""
call_id: str
@@ -153,13 +218,66 @@ class Recording:
all_silence: bool = False
async def encode_mp3(audio: bytes, path: Path) -> bool:
"""
The one and only encode in the pipeline: raw PCM in, MP3 file out.
Module-level rather than a method so tests can substitute it without
needing FFmpeg, and so the "exactly one encode per call" property is
trivially observable.
"""
if not audio:
return False
cmd = [
"ffmpeg",
"-hide_banner", "-nostdin", "-nostats",
"-loglevel", "warning", "-y",
"-f", "s16le",
"-ar", MP3_SAMPLE_RATE,
"-ac", str(pcm.CHANNELS),
"-i", "pipe:0",
"-ar", MP3_SAMPLE_RATE,
"-ac", str(pcm.CHANNELS),
"-b:a", MP3_BITRATE,
"-f", "mp3", str(path),
]
try:
proc = await asyncio.create_subprocess_exec(
*cmd,
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.PIPE,
)
except Exception as e:
logger.error(f"Could not launch the MP3 encoder ({e}) — recording not saved.")
return False
try:
_, stderr = await asyncio.wait_for(proc.communicate(audio), timeout=ENCODE_TIMEOUT_SECONDS)
except asyncio.TimeoutError:
try:
proc.kill()
except Exception:
pass
logger.error(f"MP3 encode timed out after {ENCODE_TIMEOUT_SECONDS:.0f}s — recording not saved.")
return False
except Exception as e:
logger.error(f"MP3 encode failed ({e}) — recording not saved.")
return False
if proc.returncode != 0:
logger.error(f"MP3 encode exited {proc.returncode}: {stderr.decode(errors='replace').strip()}")
return False
return True
class CallRecorder:
"""Continuous PulseAudio capture: ring buffer for pre-roll, accumulator per call."""
def __init__(self):
self._recordings_dir = Path(settings.recordings_path)
# Ring buffer: deque of (wall_clock_epoch_at_arrival, mp3_bytes).
# Ring buffer: deque of (wall_clock_epoch_at_arrival, pcm_bytes).
# Pre-roll only — see the module docstring.
self._buffer: deque[Tuple[float, bytes]] = deque()
self._buffer_bytes: int = 0
@@ -168,6 +286,10 @@ class CallRecorder:
self._proc: Optional[asyncio.subprocess.Process] = None
self._capturing: bool = False
# Voice activity, tracked continuously — not only while recording.
self._last_voice_epoch: Optional[float] = None
self._voice_onset_epoch: Optional[float] = None
# Active recording state (None when idle)
self._active: Optional[_ActiveRecording] = None
@@ -204,14 +326,12 @@ class CallRecorder:
"-hide_banner", "-nostdin", "-nostats",
"-loglevel", "warning",
"-f", "pulse", "-i", settings.pulse_source,
"-ac", "1",
"-ac", str(pcm.CHANNELS),
"-ar", MP3_SAMPLE_RATE,
"-b:a", MP3_BITRATE,
# Without this, the MP3 muxer fills its 32 KB AVIO buffer before
# writing anything — 16 s of audio per burst at 16 kbps, which would
# destroy the arrival timestamps the slicing depends on.
"-flush_packets", "1",
"-f", "mp3", "-",
# Raw PCM on stdout. No muxer, so no -flush_packets games: s16le is
# a bare byte stream and every byte FFmpeg produces is immediately
# readable, which is what keeps arrival timestamps honest.
"-f", "s16le", "-",
]
async def _capture_loop(self) -> None:
@@ -240,7 +360,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}")
logger.info(f"Starting capture: ffmpeg -f pulse -i {settings.pulse_source} (s16le/{MP3_SAMPLE_RATE}/mono)")
self._last_stderr_lines.clear()
proc = await asyncio.create_subprocess_exec(
*cmd,
@@ -252,8 +372,14 @@ class CallRecorder:
try:
assert proc.stdout is not None
while True:
chunk = await proc.stdout.read(READ_CHUNK_BYTES)
if not chunk:
# readexactly, not read: a fixed chunk keeps every analysis
# window the same length AND guarantees sample alignment, so a
# short read can never split a 16-bit sample across chunks.
try:
chunk = await proc.stdout.readexactly(READ_CHUNK_BYTES)
except asyncio.IncompleteReadError as partial:
if partial.partial:
self._ingest(partial.partial)
break # EOF — FFmpeg died or the source went away
if not self._capturing:
self._capturing = True
@@ -339,9 +465,44 @@ class CallRecorder:
except Exception:
pass
# ------------------------------------------------------------------
# Voice activity
# ------------------------------------------------------------------
def _note_voice(self, chunk: bytes, now: float) -> None:
"""
Update the continuous voice-activity marks from one arriving chunk.
A chunk carrying signal starts a NEW run whenever the gap since the last
one is at least the configured silence timeout — i.e. runs are separated
by exactly the same threshold that ends a recording, so the segmenter's
"did a new run begin" and "did the recording end" questions can never
disagree with each other.
"""
if pcm.is_silent(chunk, settings.call_silence_threshold_db):
return
gap = settings.call_silence_timeout
if self._last_voice_epoch is None or (now - self._last_voice_epoch) >= gap:
self._voice_onset_epoch = now
self._last_voice_epoch = now
def audio_activity(self) -> AudioActivity:
"""Snapshot of the capture stream for metadata_watcher (and /api/status)."""
last = self._last_voice_epoch
silence = (time.time() - last) if last is not None else 0.0
return AudioActivity(
capturing=self._capturing,
recording=self._active is not None,
last_voice_epoch=last,
voice_onset_epoch=self._voice_onset_epoch,
silence_seconds=max(0.0, silence),
)
def _ingest(self, chunk: bytes) -> None:
"""Append a chunk to the ring buffer and, if recording, the accumulator."""
now = time.time()
self._note_voice(chunk, now)
self._buffer.append((now, chunk))
self._buffer_bytes += len(chunk)
@@ -375,9 +536,10 @@ class CallRecorder:
async def start_recording(self, call_id: str, start_epoch: Optional[float] = None) -> bool:
"""
Open a recording. `start_epoch` is OP25's call_log timestamp (host wall
clock); the slice begins PRE_ROLL_SECONDS before it. Omit it only when no
OP25 timestamp is available — then we fall back to "now", losing precision.
Open a recording. `start_epoch` is the detected voice onset (host wall
clock, same clock as the chunk stamps); the slice begins
PRE_ROLL_SECONDS before it. Omit it only when no onset is available —
then we fall back to "now", losing the pre-roll's precision.
"""
if self._active is not None:
logger.warning(f"Recording already active ({self._active.call_id}) — ignoring start for {call_id}.")
@@ -396,8 +558,8 @@ class CallRecorder:
oldest = self._buffer[0][0] if self._buffer else None
if oldest is not None and slice_start < oldest:
# Pre-roll predates the buffer: node just started, capture restarted,
# or OP25's timestamp is far in the past. Clamp and say so LOUDLY —
# this is silent audio loss otherwise.
# or the onset is far in the past. Clamp and say so LOUDLY — this is
# silent audio loss otherwise.
clamped = oldest - slice_start
logger.warning(
f"BUFFER CLAMP: pre-roll for call {call_id} predates buffered audio by "
@@ -417,13 +579,28 @@ class CallRecorder:
logger.info(f"Recording started: {call_id} (slice from {slice_start:.3f})")
return True
async def discard_recording(self) -> None:
"""
Drop the open recording without writing anything.
Used when the segmenter decides the audio must not be kept — today only
the unattributed/orphan-audio path, where uploading would inject a call
with no talkgroup into correlation.
"""
active, self._active = self._active, None
if active is not None:
logger.info(f"Discarded buffered audio for {active.call_id} ({active.total_bytes} bytes).")
async def stop_recording(self, end_epoch: Optional[float] = None) -> Optional[Recording]:
"""
Close the recording and write the file. `end_epoch` is host wall clock.
Close the recording, trim it, encode it once and write the file.
`end_epoch` is host wall clock.
Waits (bounded) for captured audio to actually cover `end_epoch` before
slicing — see TAIL_WAIT_TIMEOUT_SECONDS. Returns None only when there was
no recording open or no audio at all.
slicing — see TAIL_WAIT_TIMEOUT_SECONDS. An audio-driven close never
needs that wait, because its end epoch is derived from audio that is
already buffered; a control-channel-derived close (tgid_change) does.
Returns None only when there was no recording open or no audio at all.
"""
active = self._active
if active is None:
@@ -439,19 +616,21 @@ class CallRecorder:
await self._await_tail(end, call_id)
self._active = None
chunks: List[bytes] = []
parts: List[bytes] = []
total = 0
last_ts = slice_start
for ts, chunk in active.chunks:
if ts < slice_start:
continue
chunks.append(chunk)
parts.append(chunk)
total += len(chunk)
last_ts = ts
if ts >= end:
# Include the chunk straddling `end` so the tail is never clipped,
# then stop.
break
if not chunks:
if not parts:
logger.warning(
f"No buffered audio for call {call_id} "
f"(window {slice_start:.3f}{end:.3f}) — PulseAudio capture may be down."
@@ -464,29 +643,55 @@ class CallRecorder:
"audio — the tail is short. Capture may be stalled or restarting."
)
self._recordings_dir.mkdir(parents=True, exist_ok=True)
ts_str = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
output_path = self._recordings_dir / f"{ts_str}_{call_id}.mp3"
output_path.write_bytes(b"".join(chunks))
size = output_path.stat().st_size
if size <= 0:
output_path.unlink(missing_ok=True)
logger.warning(f"Recording for call {call_id} produced an empty file.")
return None
audio_end = min(end, last_ts)
logger.info(f"Recording saved: {output_path.name} ({size} bytes, {audio_end - slice_start:.2f}s window)")
raw = b"".join(parts)
recording = Recording(
call_id=call_id,
path=output_path,
path=None,
audio_start_epoch=slice_start,
audio_end_epoch=audio_end,
audio_end_epoch=slice_start + pcm.seconds(len(raw)),
clamped_seconds=active.clamped_seconds,
)
return await self._apply_trim(recording)
return await self._finish(recording, raw)
async def _finish(self, recording: Recording, raw: bytes) -> Optional[Recording]:
"""Trim, encode exactly once, and write the MP3."""
audio = raw
if settings.trim_silence:
audio, result = audio_trim.trim_pcm(audio)
if result.all_silence:
logger.warning(
f"Call {recording.call_id} contains no speech at all — skipping upload. "
"Check squelch, the Liquidsoap output and the drb_sink monitor."
)
recording.all_silence = True
return recording
if result.applied:
recording.lead_trimmed = result.lead
recording.tail_trimmed = result.tail
# Wall clock of the trimmed audio's first and last sample.
recording.audio_start_epoch += result.lead
recording.audio_end_epoch -= result.tail
self._recordings_dir.mkdir(parents=True, exist_ok=True)
ts_str = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
output_path = self._recordings_dir / f"{ts_str}_{recording.call_id}.mp3"
if not await encode_mp3(audio, output_path):
output_path.unlink(missing_ok=True)
return None
size = output_path.stat().st_size if output_path.exists() else 0
if size <= 0:
output_path.unlink(missing_ok=True)
logger.warning(f"Recording for call {recording.call_id} produced an empty file.")
return None
recording.path = output_path
logger.info(
f"Recording saved: {output_path.name} ({size} bytes, "
f"{pcm.seconds(len(audio)):.2f}s audio)"
)
return recording
async def _await_tail(self, end: float, call_id: str) -> float:
"""
@@ -519,41 +724,6 @@ class CallRecorder:
)
return waited
async def _apply_trim(self, recording: Recording) -> Recording:
"""
Strip leading/trailing dead air and keep the timing metadata honest.
An all-silence recording is NOT uploaded: it carries no information and
silence is exactly what makes Whisper hallucinate. It is logged instead,
because it also means something is wrong with the audio path.
"""
if not settings.trim_silence or recording.path is None:
return recording
result = await audio_trim.trim_silence(
recording.path,
sample_rate=MP3_SAMPLE_RATE,
bitrate=MP3_BITRATE,
)
if result.all_silence:
logger.warning(
f"Call {recording.call_id} contains no speech at all — skipping upload. "
"Check squelch, the Liquidsoap output and the drb_sink monitor."
)
recording.path.unlink(missing_ok=True)
recording.path = None
recording.all_silence = True
return recording
if result.applied:
recording.lead_trimmed = result.lead
recording.tail_trimmed = result.tail
# Wall clock of the trimmed audio's first and last sample.
recording.audio_start_epoch += result.lead
recording.audio_end_epoch -= result.tail
return recording
# ------------------------------------------------------------------
# Upload (unchanged interface)
# ------------------------------------------------------------------
+590 -93
View File
@@ -1,81 +1,160 @@
"""
Event-driven call state machine.
Call segmentation: AUDIO decides the boundaries, the CONSOLE decides the label.
Replaces the old hang-counter inference (which derived call start from "a tgid
appeared in channel_update" and call end from N polls of silence) with the two
authoritative signals OP25 actually exposes:
START first chunk of audio above the silence threshold (voice onset).
STOP settings.call_silence_timeout seconds of continuous silence HEARD in
that audio.
LABEL talkgroup / alias / rid, resolved from OP25 console observations that
fall inside the recording's window, resolved AT CLOSE TIME.
SPLIT a console talkgroup change still forces a cut, even mid-audio.
START — a `call_log` entry. OP25 appends one at channel-grant time stamped with
its own time.time(). This is an exact start timestamp, not the moment
our poll happened to notice, so recordings can be sliced back to it.
WHY THE CONTROL CHANNEL NO LONGER DECIDES BOUNDARIES. The previous design
started a segment on an OP25 `call_log` grant and ended it by inferring from the
control channel: the `srcaddr != 0 -> 0` edge started an idle timer and the
segment closed call_idle_timeout seconds later. Both halves were measured wrong
in the field:
END — the `srcaddr` != 0 → `srcaddr` == 0 transition in `channel_update`.
OP25 never reports call termination externally: internally it ends a
call on the P25 Terminator Data Unit (duid15) or 3 voice-framing
timeouts, but neither becomes a log entry. What *is* observable is that
`srcaddr`/`svcopts` reset to 0/false the instant the call ends, while
`tgid`/`hold_tgid` keep showing the just-ended talkgroup for
TGID_HOLD_TIME (2 s). So the srcaddr edge is a real state change, not a
timeout heuristic.
* The grant fires 0.84-1.62 s (variable) before anyone speaks, so a
grant-anchored window is always guessing at the offset.
* `srcaddr` can drop to 0 WHILE SOMEONE IS STILL TALKING. Measured across six
recordings, five had healthy trailing silence trimmed (-0.53 s to -2.48 s)
but one reported "-1.61s lead, -0.00s tail" — the trim found nothing to
remove because the capture window had closed on top of live speech. The
recording ends on an unfinished word. Working backwards from its lead trim,
the audio pipeline lag was at most 1.36 s, so the window should have held
~1.6 s more; the only consistent explanation is a false early `srcaddr -> 0`.
Audio is the ground truth for WHEN. It cannot answer WHO, so the console is
still the only source of talkgroup, alias and radio id.
WHY ATTRIBUTION HAPPENS AT CLOSE, NOT AT OPEN. There is no guaranteed ordering
between a grant and the audio it belongs to: the console is polled every 500 ms
and the audio pipeline lag is variable, so the grant can land after voice onset
just as easily as before it. A segment may therefore open unattributed and
acquire its talkgroup part-way through, which is expected and fine. At close we
have seen the whole window and ask the rolling console history "what was active
during this audio, give or take a few seconds" — see _attribute and the
ATTRIBUTION_* constants.
ORPHAN AUDIO. If nothing in the console history overlaps the window, the audio
is unattributed: Liquidsoap fallback, a test tone, stray noise, or a dropped
`call_log`. Policy is DISCARD AND SHOUT — the recording is not uploaded and no
call_start/call_end is published, because a call with no talkgroup silently
poisons incident correlation downstream, and that is worse than losing the
audio. It is logged at ERROR with the window and everything nearby that was
considered, and counted on /api/status so it cannot pass unnoticed.
FALLBACK MODE. When PulseAudio capture is NOT producing audio there is nothing
to segment on, so the old console state machine still runs (grant opens,
srcaddr edge + call_idle_timeout closes). It produces no audio — capture is
down — but it keeps the node reporting real radio activity to C2 while the
audio path is broken. This is the only remaining consumer of
settings.call_idle_timeout.
SEGMENTS: one emitted call (= one recording, one Firestore doc) spans a whole
conversation, not a single transmission. It stays open across repeated grants on
the same talkgroup and closes when the talkgroup changes or the radio goes quiet
for settings.call_idle_timeout seconds.
the same talkgroup and closes when the talkgroup changes or the AUDIO goes quiet
for settings.call_silence_timeout seconds.
CLOCKS: `call_log["time"]` is time.time() inside the op25 container. All three
client containers run network_mode: host and share the host kernel clock, so that
value is directly comparable to time.time() here — no offset mapping needed. The
call recorder's ring buffer is stamped with the same clock for the same reason.
call recorder's chunk timestamps are the same clock, with the caveat that they
are ARRIVAL times and therefore lag the moment of speech by the pipeline
latency. Comparisons between two audio timestamps are exact; comparisons between
audio and console timestamps carry that lag, which is what _tail_pad() covers.
"""
import asyncio
import time
import uuid
from collections import deque
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Optional, Callable, Awaitable, Any, List, Dict
from app.config import settings
from app.internal.call_recorder import AudioActivity
from app.internal.op25_client import op25_client
from app.internal.logger import logger
CallbackFn = Callable[[dict], Awaitable[None]]
ActivityFn = Callable[[], AudioActivity]
# 500 ms. Do NOT lower: start precision already comes from OP25's own timestamp,
# and http_server.py's request handler has a ~200 ms blocking floor anyway.
# 500 ms. Do NOT lower: audio boundaries come from the recorder's own chunk
# timestamps (~46 ms resolution), not from when this loop happens to notice
# them, and http_server.py's request handler has a ~200 ms blocking floor anyway.
POLL_INTERVAL = 0.5
# Seconds of unreachable OP25 before an open segment is force-closed.
# Seconds of unreachable OP25 before an open segment is force-closed. Applies in
# both modes: without the console there is no attribution, and unattributed
# audio is discarded anyway.
OP25_OFFLINE_GRACE = 3.0
# Hard ceiling on a single segment; mirrors MAX_RECORDING_SECONDS in call_recorder
# so a talkgroup that never goes quiet cannot produce an unbounded recording.
# so a talkgroup that never goes quiet cannot produce an unbounded recording. In
# audio mode a new segment is opened immediately afterwards if voice is still
# present, so a genuinely long transmission is split rather than truncated.
MAX_SEGMENT_SECONDS = 600
# How far either side of the AUDIO window console observations are still
# accepted as attribution evidence. "Plus or minus some seconds", made explicit:
#
# LOOKBACK the grant normally PRECEDES the audio — 0.84-1.62 s of
# grant-to-speech delay, plus up to ~1.4 s of audio pipeline lag,
# plus one 0.5 s poll of detection slack. 4.0 s covers the worst
# case measured with margin.
# LOOKAHEAD the grant can also FOLLOW voice onset, because the console is only
# polled every 500 ms and OP25 logs the grant on its own schedule.
# 2.0 s is four poll intervals.
#
# Both are deliberately asymmetric: the "grant first" direction is the common
# one and has the larger physical spread.
ATTRIBUTION_LOOKBACK_SECONDS = 4.0
ATTRIBUTION_LOOKAHEAD_SECONDS = 2.0
# Rolling console history. Bounded twice — by age and by entry count — so a busy
# system cannot grow it without limit. At ~2 observations per poll this is a few
# minutes of history for a few tens of KB.
CONSOLE_HISTORY_SECONDS = 180.0
CONSOLE_HISTORY_MAX = 1200
# How far back to look for a duplicate before appending a grant. OP25's call_log
# deque drains on read so repeats should not happen, but a re-delivered entry
# would otherwise inflate the transmission count and the attribution score.
_GRANT_DEDUPE_DEPTH = 24
# Close reasons where the console explicitly told us the talkgroup changed, so
# the segment's label is already known first-hand and close-time attribution
# would only be able to make it worse (the window extends past the split).
_SPLIT_REASONS = ("tgid_change", "tgid_change_unlogged")
def _tail_pad() -> float:
"""
Audio kept after the observed end of the last transmission, so the srcaddr
edge (up to one poll late) plus encoder latency never clips the tail.
Audio kept past a CONSOLE-DERIVED segment boundary, to cover the fact that
buffered audio lags control-channel timestamps.
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 in config.py for
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).
Under audio-driven segmentation this no longer applies to the normal end of
a call — that boundary now comes from the audio itself and needs no pad. It
still applies wherever a boundary is a control-channel timestamp:
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
tgid_change close at the new grant's timestamp + pad
tgid_change_unlogged close at the observing poll's timestamp + pad
idle_timeout console fallback mode only
Read live from settings (env CALL_TAIL_PAD_SECONDS) rather than frozen into
a module constant, so it is tunable per node.
An earlier version of this docstring claimed the tgid_change paths close at
"an exact, already-known boundary" 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
timestamps by ~1.5 s (measured: 0.84-1.62 s 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.
roughly the last 1.5 s of its real speech. 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.
"""
return settings.call_tail_pad_seconds
@@ -104,6 +183,35 @@ def _iso(epoch: Optional[float]) -> Optional[str]:
return datetime.fromtimestamp(epoch, timezone.utc).isoformat()
@dataclass(frozen=True)
class ConsoleEvent:
"""One thing the OP25 console said, kept so a closing segment can ask about it."""
epoch: float
tgid: int
name: str = ""
freq: Any = None
rid: Optional[int] = None
# True for a `call_log` grant, False for an active `channel_update` row.
is_grant: bool = False
@dataclass
class Attribution:
"""Who a stretch of audio belonged to, and how confident we are."""
tgid: int
name: str = ""
freq: Any = None
rid: Optional[int] = None
grants: int = 0
# Observations that fall strictly inside the audio window (vs only inside
# the tolerance band around it).
overlap: int = 0
nearby: int = 0
competing: List[int] = field(default_factory=list)
class MetadataWatcher:
def __init__(self):
self._running = False
@@ -114,21 +222,38 @@ class MetadataWatcher:
self._current_tgid_name: Optional[str] = None
self._current_freq: Any = None
self._current_srcaddr: Optional[int] = None
self._started_at: Optional[float] = None # OP25 epoch of the first grant
self._started_at: Optional[float] = None # audio onset, or grant epoch in fallback mode
self._transmissions: int = 0
# True when the open segment is governed by audio, False for the
# console fallback. Fixed at open so capture flapping cannot switch the
# rules underneath a live segment.
self._audio_driven: bool = False
# Transmission tracking within the open segment
# Transmission tracking within the open segment (console fallback mode)
self._tx_active: bool = False # last poll saw srcaddr != 0
self._last_activity: float = 0.0 # epoch of last evidence of traffic
self._last_tx_end: Optional[float] = None # epoch of the srcaddr 1→0 edge
self._last_ok_poll: float = 0.0
# Rolling console history for close-time attribution.
self._console: deque[ConsoleEvent] = deque(maxlen=CONSOLE_HISTORY_MAX)
# Onset of the voice run the last audio-driven segment covered, so the
# same run cannot immediately re-open a second segment.
self._consumed_onset: Optional[float] = None
# Field-visible counter of discarded orphan audio.
self._unattributed_segments: int = 0
# Injectable for tests; production is always the host wall clock.
self._clock: Callable[[], float] = time.time
# Set these before calling start()
self.on_call_start: Optional[CallbackFn] = None
self.on_call_end: Optional[CallbackFn] = None
# Supplies the audio-activity snapshot. None (or a snapshot reporting
# capturing=False) puts the watcher in console fallback mode.
self.audio_activity: Optional[ActivityFn] = None
# ------------------------------------------------------------------
# Lifecycle
@@ -138,7 +263,7 @@ class MetadataWatcher:
self._running = True
self._last_ok_poll = self._clock()
asyncio.create_task(self._poll_loop())
logger.info("Metadata watcher started (call_log driven).")
logger.info("Metadata watcher started (audio-driven segmentation, console attribution).")
async def stop(self):
self._running = False
@@ -168,6 +293,307 @@ class MetadataWatcher:
return
self._last_ok_poll = now
self._record_console(update, now)
activity = self._snapshot()
if activity is None or not activity.capturing:
await self._console_tick(update, now)
return
await self._audio_tick(update, activity, now)
def _snapshot(self) -> Optional[AudioActivity]:
if self.audio_activity is None:
return None
try:
return self.audio_activity()
except Exception as e:
logger.warning(f"Audio activity unavailable ({e}) — falling back to console segmentation.")
return None
# ------------------------------------------------------------------
# Console history (feeds close-time attribution)
# ------------------------------------------------------------------
def _record_console(self, update: Any, now: float) -> None:
for entry in update.call_log:
tgid = _as_int(entry.get("tgid"))
if tgid is None:
continue
epoch = _as_float(entry.get("time"))
event = ConsoleEvent(
epoch=now if epoch is None else epoch,
tgid=tgid,
name=entry.get("tgtag") or "",
freq=entry.get("freq"),
rid=_as_int(entry.get("rid")),
is_grant=True,
)
if not self._is_duplicate_grant(event):
self._console.append(event)
for channel in update.channels:
tgid = _as_int(channel.get("tgid"))
srcaddr = _as_int(channel.get("srcaddr"))
if tgid is None or srcaddr is None:
continue # idle channel says nothing about who is talking
self._console.append(ConsoleEvent(
epoch=now,
tgid=tgid,
name=channel.get("tag") or "",
freq=channel.get("freq"),
rid=srcaddr,
is_grant=False,
))
cutoff = now - CONSOLE_HISTORY_SECONDS
while self._console and self._console[0].epoch < cutoff:
self._console.popleft()
def _is_duplicate_grant(self, event: ConsoleEvent) -> bool:
for index in range(len(self._console) - 1, -1, -1):
if len(self._console) - index > _GRANT_DEDUPE_DEPTH:
return False
known = self._console[index]
if known.is_grant and known.tgid == event.tgid and known.epoch == event.epoch:
return True
return False
def _attribute(self, start: float, end: float) -> Optional[Attribution]:
"""
Resolve which talkgroup a stretch of audio belongs to.
Scores every talkgroup seen in [start - LOOKBACK, end + LOOKAHEAD] by
how well its console activity overlaps the audio itself, preferring
real overlap over merely being nearby, and grants over channel rows.
Returns None only when NOTHING was observed in that band at all — the
orphan-audio case.
"""
low = start - ATTRIBUTION_LOOKBACK_SECONDS
high = end + ATTRIBUTION_LOOKAHEAD_SECONDS
candidates: Dict[int, Attribution] = {}
firsts: Dict[int, float] = {}
for event in self._console:
if event.epoch < low or event.epoch > high:
continue
found = candidates.get(event.tgid)
if found is None:
found = Attribution(tgid=event.tgid)
candidates[event.tgid] = found
firsts[event.tgid] = event.epoch
found.nearby += 1
if start <= event.epoch <= end:
found.overlap += 1
if event.is_grant:
found.grants += 1
if event.name and not found.name:
found.name = event.name
if event.freq and found.freq is None:
found.freq = event.freq
if event.rid is not None:
found.rid = event.rid # most recent wins
if not candidates:
return None
best = max(
candidates.values(),
key=lambda a: (a.overlap, a.grants, a.nearby, -firsts[a.tgid]),
)
best.competing = sorted(
tgid for tgid, a in candidates.items() if tgid != best.tgid and a.overlap > 0
)
return best
# ------------------------------------------------------------------
# Audio-driven segmentation
# ------------------------------------------------------------------
async def _audio_tick(self, update: Any, activity: AudioActivity, now: float) -> None:
if self._active_call_id is not None and not self._audio_driven:
# A segment that opened while capture was down finishes under the
# rules it started with rather than switching mid-flight.
await self._console_tick(update, now)
return
# 1. Console first: a talkgroup change must still force a split even
# when the audio never went quiet, and a grant may be the thing that
# finally attributes an already-open segment.
for entry in sorted(update.call_log, key=lambda e: _as_float(e.get("time")) or 0.0):
await self._handle_grant(entry, now)
await self._scan_channels(update.channels, now)
# 2. Then the audio decides the boundaries.
last_voice = activity.last_voice_epoch
voice_active = last_voice is not None and (now - last_voice) < settings.call_silence_timeout
if self._active_call_id is None:
onset = activity.voice_onset_epoch
if voice_active and onset is not None and (
self._consumed_onset is None or onset > self._consumed_onset
):
await self._open_from_audio(onset, now)
return
if not voice_active:
silence = (now - last_voice) if last_voice is not None else settings.call_silence_timeout
# The measured trailing silence, in the AUDIO's own clock. This is
# the number to tune settings.call_silence_timeout from — unlike the
# old control-channel idle it contains no grant-to-speech delay, so
# it means exactly what it says.
logger.info(
f"Audio silence close for tgid {self._current_tgid}: measured trailing silence "
f"{silence:.2f}s (threshold {settings.call_silence_timeout:.2f}s at "
f"{settings.call_silence_threshold_db:.1f}dBFS)."
)
self._consumed_onset = activity.voice_onset_epoch
end = (last_voice + settings.call_silence_timeout) if last_voice is not None else now
await self._close_segment(min(end, now), reason="audio_silence")
return
if self._started_at is not None and (now - self._started_at) >= MAX_SEGMENT_SECONDS:
logger.warning(
f"Segment for tgid {self._current_tgid} hit the {MAX_SEGMENT_SECONDS}s cap while audio "
"was still live — closing and immediately reopening so nothing is dropped. If this "
"repeats, the silence threshold may be low enough that noise reads as voice."
)
await self._close_segment(now, reason="max_length")
await self._open_from_audio(now, now)
async def _open_from_audio(self, onset: float, now: float) -> None:
"""Open a segment at a detected voice onset, attributing it if we can."""
found = self._attribute(onset, now)
await self._open_segment(
started_at=onset,
now=now,
tgid=found.tgid if found else None,
tgid_name=found.name if found else "",
freq=found.freq if found else None,
srcaddr=found.rid if found else None,
audio_driven=True,
transmissions=found.grants if found else 0,
)
async def _handle_grant(self, entry: Dict[str, Any], now: float) -> None:
"""A `call_log` grant, interpreted in audio mode: label or split, never start."""
tgid = _as_int(entry.get("tgid"))
if tgid is None:
return # a grant with no talkgroup is nothing we can label with
started_at = _as_float(entry.get("time"))
if started_at is None:
logger.warning(f"call_log entry for tgid={tgid} has no usable time — using local clock.")
started_at = now
if self._active_call_id is None:
# Audio starts recordings, not grants. The grant is already in the
# console history and will attribute the segment when audio arrives.
return
if self._current_tgid is None:
self._current_tgid = tgid
self._current_tgid_name = entry.get("tgtag") or ""
self._current_freq = entry.get("freq")
self._current_srcaddr = _as_int(entry.get("rid"))
self._transmissions += 1
logger.info(
f"Late attribution: segment {self._active_call_id} adopted tgid {tgid} from a grant "
f"logged {started_at - (self._started_at or started_at):+.2f}s from audio onset."
)
return
if tgid == self._current_tgid:
# CONTINUE: same talkgroup, keep one recording so the back-and-forth
# of a single conversation lands in one file.
self._transmissions += 1
self._refresh_meta_from_log(entry)
return
# FORCED SPLIT. Two talkgroups can be back to back with no silence
# between them; pure audio segmentation would merge them into one file
# under one label, which is exactly the kind of wrong that corrupts
# incident correlation. The console change is authoritative here.
await self._close_segment(started_at + _tail_pad(), reason="tgid_change")
await self._open_segment(
started_at=started_at,
now=now,
tgid=tgid,
tgid_name=entry.get("tgtag") or "",
freq=entry.get("freq"),
srcaddr=_as_int(entry.get("rid")),
audio_driven=True,
transmissions=1,
)
async def _scan_channels(self, channels: List[Dict[str, Any]], now: float) -> None:
"""Channel rows in audio mode: refresh metadata, catch an unlogged split."""
if self._active_call_id is None:
return
active: List[Dict[str, Any]] = []
ours = False
for channel in channels:
tgid = _as_int(channel.get("tgid"))
srcaddr = _as_int(channel.get("srcaddr"))
if tgid is None or srcaddr is None:
continue
active.append(channel)
if tgid == self._current_tgid:
ours = True
self._current_srcaddr = srcaddr
self._last_activity = now
self._refresh_meta_from_channel(channel)
if self._current_tgid is None:
# Late attribution from a channel row — this is the path that saves
# us when the grant itself was dropped from OP25's capped deque.
if len(active) == 1:
tgid = _as_int(active[0].get("tgid"))
self._current_tgid = tgid
self._current_tgid_name = active[0].get("tag") or ""
self._current_freq = active[0].get("freq")
self._current_srcaddr = _as_int(active[0].get("srcaddr"))
logger.info(f"Late attribution: segment {self._active_call_id} adopted tgid {tgid} from channel state.")
return
if ours or not active or len(channels) != 1:
# Restricted to single-receiver setups on purpose: with several
# receivers, another channel being busy says nothing about ours.
return
foreign = _as_int(active[0].get("tgid"))
if foreign is None or foreign == self._current_tgid:
return
logger.warning(
f"tgid {foreign} active without a call_log entry — splitting segment for tgid "
f"{self._current_tgid} (call_log event likely dropped)."
)
await self._close_segment(now + _tail_pad(), reason="tgid_change_unlogged")
await self._open_segment(
started_at=now,
now=now,
tgid=foreign,
tgid_name=active[0].get("tag") or "",
freq=active[0].get("freq"),
srcaddr=_as_int(active[0].get("srcaddr")),
audio_driven=True,
transmissions=1,
)
# ------------------------------------------------------------------
# Console fallback segmentation (capture down)
# ------------------------------------------------------------------
async def _console_tick(self, update: Any, now: float) -> None:
if self._active_call_id is not None and self._audio_driven:
logger.warning(
f"PulseAudio capture stopped while recording {self._active_call_id} — closing the "
"segment at the last captured audio; segmentation falls back to the control channel."
)
await self._close_segment(now, reason="capture_lost")
return
# 1. call_log first — these are the authoritative starts, and processing
# them before the channel scan means a same-poll grant+state pair is
@@ -176,27 +602,24 @@ class MetadataWatcher:
for entry in sorted(update.call_log, key=lambda e: _as_float(e.get("time")) or 0.0):
await self._handle_call_log(entry, now)
# 2. channel_update — the only external end signal.
# 2. channel_update — the only external end signal available here.
await self._handle_channels(update.channels, now)
async def _handle_call_log(self, entry: Dict[str, Any], now: float) -> None:
tgid = _as_int(entry.get("tgid"))
if tgid is None:
return # a grant with no talkgroup is nothing we can record or label
return
# OP25's own stamp. Fall back to now only if the field is missing/garbage.
started_at = _as_float(entry.get("time"))
if started_at is None:
logger.warning(f"call_log entry for tgid={tgid} has no usable time — using local clock.")
started_at = now
if self._active_call_id is None:
await self._open_segment(entry, tgid, started_at, now)
await self._open_from_console(entry, tgid, started_at, now)
return
if tgid == self._current_tgid:
# CONTINUE: same talkgroup, keep one recording so the back-and-forth
# of a single conversation lands in one file.
self._transmissions += 1
self._tx_active = True
self._last_tx_end = None
@@ -204,18 +627,8 @@ class MetadataWatcher:
self._refresh_meta_from_log(entry)
return
# SPLIT: different talkgroup. The new grant's OP25 timestamp is the most
# 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)
await self._open_from_console(entry, tgid, started_at, now)
async def _handle_channels(self, channels: List[Dict[str, Any]], now: float) -> None:
if self._active_call_id is None:
@@ -241,19 +654,13 @@ class MetadataWatcher:
self._last_tx_end = None
self._last_activity = now
elif self._tx_active:
# The srcaddr != 0 → 0 edge: OP25 has torn the call down.
# The srcaddr != 0 → 0 edge. Note this is NOT trusted as an end of
# speech any more (it fires mid-word in the field) — in fallback
# mode there is simply nothing better available.
self._tx_active = False
self._last_tx_end = now
self._last_activity = now
# Safety net for a dropped call_log event (deque is capped at 10): the one
# receiver we have is plainly on another talkgroup, so our segment is over
# even though we never saw its grant. Close now rather than record
# call_idle_timeout seconds of the wrong tgid.
#
# Restricted to single-receiver setups on purpose: with several receivers,
# another channel being busy says nothing about ours, and closing on it
# would truncate every call whenever a second receiver is active.
if not tx_active and foreign_active_tgid is not None and len(channels) == 1:
logger.warning(
f"tgid {foreign_active_tgid} active without a call_log entry — "
@@ -263,14 +670,6 @@ class MetadataWatcher:
return
if (now - self._last_activity) >= settings.call_idle_timeout:
# STOP: quiet for long enough. End the audio at the last transmission
# plus a short pad, not at "now" — otherwise every recording carries
# call_idle_timeout seconds of silence.
#
# The measured idle below is the CONTROL-CHANNEL idle (srcaddr 1→0
# edge → now). It is NOT comparable to silence measured in the audio,
# which additionally contains the ~1.9 s P25 grant→speech delay.
# Tune settings.call_idle_timeout from THIS number and nothing else.
if self._last_tx_end is not None:
measured_idle = now - self._last_tx_end
end = self._last_tx_end + _tail_pad()
@@ -292,6 +691,18 @@ class MetadataWatcher:
logger.warning(f"Segment for tgid {self._current_tgid} hit the {MAX_SEGMENT_SECONDS}s cap — closing.")
await self._close_segment(now, reason="max_length")
async def _open_from_console(self, entry: Dict[str, Any], tgid: int, started_at: float, now: float) -> None:
await self._open_segment(
started_at=started_at,
now=now,
tgid=tgid,
tgid_name=entry.get("tgtag") or "",
freq=entry.get("freq"),
srcaddr=_as_int(entry.get("rid")),
audio_driven=False,
transmissions=1,
)
# ------------------------------------------------------------------
# Segment open / close
# ------------------------------------------------------------------
@@ -311,35 +722,48 @@ class MetadataWatcher:
if not self._current_freq and channel.get("freq"):
self._current_freq = channel.get("freq")
async def _open_segment(self, entry: Dict[str, Any], tgid: int, started_at: float, now: float) -> None:
async def _open_segment(
self,
started_at: float,
now: float,
tgid: Optional[int],
tgid_name: str,
freq: Any,
srcaddr: Optional[int],
audio_driven: bool,
transmissions: int = 1,
) -> None:
self._active_call_id = str(uuid.uuid4())
self._current_tgid = tgid
self._current_tgid_name = entry.get("tgtag") or ""
self._current_freq = entry.get("freq")
self._current_srcaddr = _as_int(entry.get("rid"))
self._current_tgid_name = tgid_name
self._current_freq = freq
self._current_srcaddr = srcaddr
self._started_at = started_at
self._transmissions = 1
self._transmissions = max(1, transmissions)
self._audio_driven = audio_driven
# Assume the transmission is still up: we learn otherwise from the next
# channel scan. A grant whose call already ended before we polled simply
# closes on the very next tick via the idle timeout.
self._tx_active = True
# Console fallback assumes the transmission is still up; it learns
# otherwise from the next channel scan.
self._tx_active = not audio_driven
self._last_tx_end = None
self._last_activity = now
payload = {
"call_id": self._active_call_id,
"tgid": tgid,
"tgid_name": self._current_tgid_name,
"freq": self._current_freq,
"srcaddr": self._current_srcaddr,
"tgid_name": tgid_name,
"freq": freq,
"srcaddr": srcaddr,
"started_at": _iso(started_at),
# Raw epoch for the recorder's ring-buffer slice — same clock domain.
"started_at_epoch": started_at,
"attributed": tgid is not None,
"driver": "audio" if audio_driven else "console",
}
source = "audio onset" if audio_driven else "op25 grant"
logger.info(
f"Call start: tgid={tgid} id={self._active_call_id} "
f"(op25 t={started_at:.3f}, detected {now - started_at:+.2f}s later)"
f"({source} t={started_at:.3f}, detected {now - started_at:+.2f}s later)"
)
if self.on_call_start:
await self.on_call_start(payload)
@@ -352,6 +776,10 @@ class MetadataWatcher:
if started_at is not None:
end_epoch = max(end_epoch, started_at)
if self._audio_driven and reason not in _SPLIT_REASONS:
self._resolve_attribution(started_at if started_at is not None else end_epoch, end_epoch)
attributed = self._current_tgid is not None
payload = {
"call_id": self._active_call_id,
"tgid": self._current_tgid,
@@ -364,12 +792,29 @@ class MetadataWatcher:
"ended_at_epoch": end_epoch,
"transmissions": self._transmissions,
"end_reason": reason,
"attributed": attributed,
"driver": "audio" if self._audio_driven else "console",
}
duration = (end_epoch - started_at) if started_at is not None else 0.0
logger.info(
f"Call end: id={self._active_call_id} tgid={self._current_tgid} "
f"reason={reason} transmissions={self._transmissions} duration={duration:.2f}s"
)
if not attributed:
self._unattributed_segments += 1
window_start = started_at if started_at is not None else end_epoch
logger.error(
f"ORPHAN AUDIO: {duration:.2f}s of audio ({self._active_call_id}, reason={reason}, "
f"window {window_start:.3f}-{end_epoch:.3f}) had NO OP25 talkgroup anywhere within "
f"{ATTRIBUTION_LOOKBACK_SECONDS:.0f}s before or {ATTRIBUTION_LOOKAHEAD_SECONDS:.0f}s "
f"after it. It will be DISCARDED, not uploaded — an untagged call would poison "
f"incident correlation. Causes: Liquidsoap fallback/test audio on drb_sink, OP25 not "
f"decoding the control channel, or a dropped call_log. Console history holds "
f"{len(self._console)} recent observations; total orphans this run: "
f"{self._unattributed_segments}."
)
else:
logger.info(
f"Call end: id={self._active_call_id} tgid={self._current_tgid} "
f"reason={reason} transmissions={self._transmissions} duration={duration:.2f}s"
)
# Clear state before awaiting so a re-entrant tick can't see a half-closed
# segment (and so an immediately-following _open_segment is clean).
@@ -382,10 +827,57 @@ class MetadataWatcher:
self._transmissions = 0
self._tx_active = False
self._last_tx_end = None
self._audio_driven = False
if self.on_call_end:
await self.on_call_end(payload)
def _resolve_attribution(self, start: float, end: float) -> None:
"""
Last chance to label an audio-driven segment, run at close.
Only ADOPTS a talkgroup when the segment still has none. A tgid we
already hold came from a grant or a channel row — the console stating
outright who was transmitting — and an inference over a window is not
allowed to overrule a direct statement. This matters because the window
deliberately extends past the audio (ATTRIBUTION_LOOKAHEAD_SECONDS, and
the tail pad on a split), so a neighbouring call's console activity can
legitimately fall inside it.
A disagreement is still worth knowing about, so it is logged: it means
two talkgroups' console activity overlaps one recording, i.e. the split
logic should have fired and did not.
"""
found = self._attribute(start, end)
if found is None:
return
if self._current_tgid is None:
logger.info(
f"Attributed {self._active_call_id} at close to tgid {found.tgid} "
f"(overlap {found.overlap}, grants {found.grants}, nearby {found.nearby})."
)
self._current_tgid = found.tgid
if found.name:
self._current_tgid_name = found.name
if found.freq is not None and not self._current_freq:
self._current_freq = found.freq
if found.rid is not None and self._current_srcaddr is None:
self._current_srcaddr = found.rid
self._transmissions = max(self._transmissions, found.grants)
return
others = sorted(set(found.competing) | ({found.tgid} if found.tgid != self._current_tgid else set()))
others = [tgid for tgid in others if tgid != self._current_tgid]
if others:
logger.warning(
f"Segment {self._active_call_id} (tgid {self._current_tgid}) overlaps console "
f"activity for {others} as well — the split logic should have fired and did not. "
"Keeping the talkgroup the console stated directly."
)
if not self._current_tgid_name and found.tgid == self._current_tgid and found.name:
self._current_tgid_name = found.name
# ------------------------------------------------------------------
# Public state (consumed by routers/api.py, main.py and the dashboards)
# ------------------------------------------------------------------
@@ -406,5 +898,10 @@ class MetadataWatcher:
def is_active(self) -> bool:
return self._active_call_id is not None
@property
def unattributed_segments(self) -> int:
"""Orphan-audio segments discarded since start. Surfaced on /api/status."""
return self._unattributed_segments
metadata_watcher = MetadataWatcher()
+131
View File
@@ -0,0 +1,131 @@
"""
Raw PCM primitives: the one place that knows the capture format.
The capture pipeline buffers RAW PCM (signed 16-bit little-endian, mono,
22050 Hz) instead of MP3. Three things fall out of that, and they are the whole
reason for the change:
1. Silence detection is integer arithmetic over the bytes as they arrive —
no decode, no FFmpeg, no second process. That is what makes an
AUDIO-DRIVEN call boundary possible at all.
2. Trimming becomes a byte-offset slice instead of a second encode pass.
3. MP3 encoding happens exactly ONCE, at save time, so uploads stop being
double-encoded.
WHY SILENCE IS UNAMBIGUOUS HERE: between transmissions the captured stream is
the monitor of a PulseAudio *null sink*, which emits digital silence, not an
analog noise floor. Measured on a live node, the gap between transmissions sits
at about -91 dBFS — that is 20*log10(1/32768), i.e. one least-significant bit,
the quietest thing a 16-bit sample can be without being exactly zero. Speech on
the same node averages about -18 dBFS. There is therefore ~70 dB of daylight
between "silence" and "voice", and the threshold does NOT need field
calibration against radio noise the way an analog squelch tail would.
MEASUREMENT IS RMS, NOT PEAK. Peak would be cheaper but a single decoder click
would read as voice for a whole window; RMS over a window is the honest
"is there signal here" answer. The cost is a Python loop over the window's
samples, which is affordable because of how little audio is ever scanned:
one ~46 ms chunk per chunk arrival at capture time, and only the head/tail of a
finished recording at trim time (see audio_trim.MAX_SCAN_SECONDS). A cheap
all-zero fast path in C skips the loop entirely for exactly-silent windows.
BYTE ORDER: FFmpeg is asked for s16le. `array("h")` is native-endian, so on a
big-endian host the samples are byte-swapped before use. Every DRB target is
little-endian today; this is three lines of insurance, not a real scenario.
"""
import math
import sys
from array import array
from typing import Union
# Capture format. MP3_SAMPLE_RATE in call_recorder must stay equal to
# SAMPLE_RATE — the encode at save time is a straight pass with no resample.
SAMPLE_RATE = 22050
SAMPLE_WIDTH = 2
CHANNELS = 1
FRAME_BYTES = SAMPLE_WIDTH * CHANNELS
BYTES_PER_SECOND = SAMPLE_RATE * FRAME_BYTES # 44100 B/s
# 16-bit full scale. A sample of 32768 (or -32768) is 0 dBFS.
FULL_SCALE = 32768.0
# Reported for a window with no signal at all. Any real threshold is far above
# this, so it always compares as "silent" without special-casing log10(0).
SILENT_DBFS = -120.0
_NEEDS_BYTESWAP = sys.byteorder != "little"
Buffer = Union[bytes, bytearray]
def align(nbytes: int) -> int:
"""Round a byte count DOWN to a whole number of samples."""
if nbytes <= 0:
return 0
return nbytes - (nbytes % FRAME_BYTES)
def seconds(nbytes: int) -> float:
"""Duration of `nbytes` of PCM."""
return nbytes / BYTES_PER_SECOND
def byte_offset(sec: float) -> int:
"""Sample-aligned byte offset of `sec` seconds into a PCM buffer."""
return align(int(sec * BYTES_PER_SECOND))
def samples(buf: Buffer) -> array:
"""View a PCM buffer as signed 16-bit samples, dropping any partial frame."""
usable = align(len(buf))
data = array("h")
if usable:
data.frombytes(bytes(buf[:usable]))
if _NEEDS_BYTESWAP:
data.byteswap()
return data
def is_all_zero(buf: Buffer) -> bool:
"""
True when every byte is zero — exact digital silence.
`bytes.count` runs in C, so this is the cheap path that lets a long scan
over silence stay fast without touching the per-sample loop below.
"""
return len(buf) > 0 and buf.count(0) == len(buf)
def rms(buf: Buffer) -> float:
"""Root-mean-square amplitude in raw sample units (0 .. 32768)."""
data = samples(buf)
if not data:
return 0.0
total = 0
for sample in data:
total += sample * sample
return math.sqrt(total / len(data))
def rms_dbfs(buf: Buffer) -> float:
"""RMS level of a PCM window in dBFS. SILENT_DBFS for an empty/zero window."""
if not buf or is_all_zero(buf):
return SILENT_DBFS
value = rms(buf)
if value <= 0.0:
return SILENT_DBFS
return 20.0 * math.log10(min(value, FULL_SCALE) / FULL_SCALE)
def is_silent(buf: Buffer, threshold_db: float) -> bool:
"""
True when a PCM window carries no signal above `threshold_db` (dBFS RMS).
An empty buffer counts as silence: "no audio arrived" must never read as
"someone is talking", or a stalled capture would hold a segment open.
"""
if not buf:
return True
if is_all_zero(buf):
return True
return rms_dbfs(buf) < threshold_db