Node: report every radio heard on a call, not just the last (server-26#304)
CI / lint (push) Successful in 8s
CI / test-op25 (push) Successful in 16s
CI / test (push) Successful in 50s
Build edge-node / build (push) Successful in 9m46s

P1 of the radio-ID plan. metadata_watcher records each transmission as
[radio, first seen, last seen] from channel rows and call_log grants in both
audio and console modes. Sightings of the same radio under 2 s apart are
merged, because the P0 probe found the ID in only 44% of polls during a
burst. call_end gains `radios: [{radio, start, end}]`.

main.py rebases the offsets into the trimmed audio file's frame, so
wall clock = audio_start_epoch + offset, the mapping C2 already uses. It
also sends the list on /upload (UploadMeta.radios), because C2 starts the
pipeline from /upload before call_end arrives.

srcaddr keeps its meaning (latest talker). Metadata only: no boundary,
timestamp or attribution change (drb-audio-review).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Logan Cusano
2026-11-08 23:03:18 -05:00
co-authored by Claude Opus 5.5
parent c57a5b9068
commit 6330609a75
6 changed files with 172 additions and 1 deletions
@@ -785,6 +785,7 @@ class CallRecorder:
system_id: Optional[str] = None, system_id: Optional[str] = None,
audio_start_epoch: Optional[float] = None, audio_start_epoch: Optional[float] = None,
audio_end_epoch: Optional[float] = None, audio_end_epoch: Optional[float] = None,
radios: Optional[str] = None,
) -> Optional[str]: ) -> Optional[str]:
""" """
Upload once, inline. Returns the storage URL on success. Upload once, inline. Returns the storage URL on success.
@@ -802,6 +803,7 @@ class CallRecorder:
meta = UploadMeta( meta = UploadMeta(
call_id=call_id, talkgroup_id=talkgroup_id, talkgroup_name=talkgroup_name, call_id=call_id, talkgroup_id=talkgroup_id, talkgroup_name=talkgroup_name,
system_id=system_id, audio_start_epoch=audio_start_epoch, audio_end_epoch=audio_end_epoch, system_id=system_id, audio_start_epoch=audio_start_epoch, audio_end_epoch=audio_end_epoch,
radios=radios,
) )
result = await self._post_inline(file_path, meta) result = await self._post_inline(file_path, meta)
self.spool.last_result = {"call_id": call_id, "kind": result.kind, "status": result.status, self.spool.last_result = {"call_id": call_id, "kind": result.kind, "status": result.status,
@@ -132,6 +132,11 @@ ATTRIBUTION_LOOKAHEAD_SECONDS = 2.0
CONSOLE_HISTORY_SECONDS = 180.0 CONSOLE_HISTORY_SECONDS = 180.0
CONSOLE_HISTORY_MAX = 1200 CONSOLE_HISTORY_MAX = 1200
# Sightings of the same radio closer together than this are one transmission.
# The radio ID shows up in only ~44% of 0.5 s polls during a burst (server-26#304
# P0 probe, 2026-11-08), so gaps of a few polls inside one key-up are normal.
TX_MERGE_GAP_SECONDS = 2.0
# How far back to look for a duplicate before appending a grant. OP25's call_log # 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 # deque drains on read so repeats should not happen, but a re-delivered entry
# would otherwise inflate the transmission count and the attribution score. # would otherwise inflate the transmission count and the attribution score.
@@ -207,6 +212,21 @@ def _talker(channel: Dict[str, Any]) -> Optional[tuple]:
return tgid, rid return tgid, rid
def rebase_radios(radios: List[Dict[str, Any]], shift: float, span: float) -> List[Dict[str, Any]]:
"""Move call-relative radio offsets into another frame (the trimmed audio file).
`shift` is call start minus the new origin; offsets land clamped to [0, span]
so a talker before the file's first sample sits at 0, not before it.
"""
span = max(0.0, span)
return [
{**r,
"start": round(min(max(0.0, r["start"] + shift), span), 2),
"end": round(min(max(0.0, r["end"] + shift), span), 2)}
for r in radios
]
def _iso(epoch: Optional[float]) -> Optional[str]: def _iso(epoch: Optional[float]) -> Optional[str]:
if epoch is None: if epoch is None:
return None return None
@@ -254,6 +274,10 @@ class MetadataWatcher:
self._current_srcaddr: Optional[int] = None self._current_srcaddr: Optional[int] = None
self._started_at: Optional[float] = None # audio onset, or grant epoch in fallback mode self._started_at: Optional[float] = None # audio onset, or grant epoch in fallback mode
self._transmissions: int = 0 self._transmissions: int = 0
# Every radio heard on the open segment, in order: [rid, first_epoch,
# last_epoch]. srcaddr above keeps only the latest one; one recording
# often holds a dispatcher and a unit (server-26#304).
self._tx: List[List[Any]] = []
# True when the open segment is governed by audio, False for the # True when the open segment is governed by audio, False for the
# console fallback. Fixed at open so capture flapping cannot switch the # console fallback. Fixed at open so capture flapping cannot switch the
# rules underneath a live segment. # rules underneath a live segment.
@@ -559,6 +583,7 @@ class MetadataWatcher:
self._current_tgid_name = entry.get("tgtag") or "" self._current_tgid_name = entry.get("tgtag") or ""
self._current_freq = entry.get("freq") self._current_freq = entry.get("freq")
self._current_srcaddr = _as_int(entry.get("rid")) self._current_srcaddr = _as_int(entry.get("rid"))
self._note_tx(self._current_srcaddr, started_at)
self._transmissions += 1 self._transmissions += 1
logger.info( logger.info(
f"Late attribution: segment {self._active_call_id} adopted tgid {tgid} from a grant " f"Late attribution: segment {self._active_call_id} adopted tgid {tgid} from a grant "
@@ -571,11 +596,13 @@ class MetadataWatcher:
# of a single conversation lands in one file. # of a single conversation lands in one file.
self._transmissions += 1 self._transmissions += 1
self._refresh_meta_from_log(entry) self._refresh_meta_from_log(entry)
self._note_tx(_as_int(entry.get("rid")), started_at)
return return
if self._same_channel(tgid, entry.get("freq"), entry.get("tgtag") or ""): if self._same_channel(tgid, entry.get("freq"), entry.get("tgtag") or ""):
self._transmissions += 1 self._transmissions += 1
self._refresh_meta_from_log(entry) self._refresh_meta_from_log(entry)
self._note_tx(_as_int(entry.get("rid")), started_at)
return return
# FORCED SPLIT. Two talkgroups can be back to back with no silence # FORCED SPLIT. Two talkgroups can be back to back with no silence
@@ -626,6 +653,7 @@ class MetadataWatcher:
ours = True ours = True
if srcaddr is not None: if srcaddr is not None:
self._current_srcaddr = srcaddr self._current_srcaddr = srcaddr
self._note_tx(srcaddr, now)
self._last_activity = now self._last_activity = now
self._refresh_meta_from_channel(channel) self._refresh_meta_from_channel(channel)
@@ -638,6 +666,7 @@ class MetadataWatcher:
self._current_tgid_name = active[0].get("tag") or "" self._current_tgid_name = active[0].get("tag") or ""
self._current_freq = active[0].get("freq") self._current_freq = active[0].get("freq")
self._current_srcaddr = _as_int(active[0].get("srcaddr")) self._current_srcaddr = _as_int(active[0].get("srcaddr"))
self._note_tx(self._current_srcaddr, now)
logger.info(f"Late attribution: segment {self._active_call_id} adopted tgid {tgid} from channel state.") logger.info(f"Late attribution: segment {self._active_call_id} adopted tgid {tgid} from channel state.")
return return
@@ -709,6 +738,7 @@ class MetadataWatcher:
self._last_tx_end = None self._last_tx_end = None
self._last_activity = now self._last_activity = now
self._refresh_meta_from_log(entry) self._refresh_meta_from_log(entry)
self._note_tx(_as_int(entry.get("rid")), started_at)
return return
await self._close_segment(started_at + _tail_pad(), reason="tgid_change") await self._close_segment(started_at + _tail_pad(), reason="tgid_change")
@@ -730,6 +760,7 @@ class MetadataWatcher:
tx_active = True tx_active = True
if srcaddr is not None: if srcaddr is not None:
self._current_srcaddr = srcaddr self._current_srcaddr = srcaddr
self._note_tx(srcaddr, now)
self._refresh_meta_from_channel(channel) self._refresh_meta_from_channel(channel)
elif chan_tgid is not None: elif chan_tgid is not None:
foreign_active_tgid = chan_tgid foreign_active_tgid = chan_tgid
@@ -792,6 +823,28 @@ class MetadataWatcher:
# Segment open / close # Segment open / close
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def _note_tx(self, rid: Optional[int], epoch: float) -> None:
"""Record a sighting of `rid` talking on the open segment."""
if rid is None or self._active_call_id is None:
return
last = self._tx[-1] if self._tx else None
if last is not None and last[0] == rid and epoch - last[2] <= TX_MERGE_GAP_SECONDS:
last[2] = max(last[2], epoch)
return
self._tx.append([rid, epoch, epoch])
def _radios(self, start: float, end: float) -> List[Dict[str, Any]]:
"""The segment's transmissions as offsets (seconds) from its start, clamped to it."""
span = max(0.0, end - start)
return [
{
"radio": rid,
"start": round(min(max(0.0, first - start), span), 2),
"end": round(min(max(0.0, last - start), span), 2),
}
for rid, first, last in self._tx
]
def _refresh_meta_from_log(self, entry: Dict[str, Any]) -> None: def _refresh_meta_from_log(self, entry: Dict[str, Any]) -> None:
if not self._current_tgid_name: if not self._current_tgid_name:
self._current_tgid_name = entry.get("tgtag") or "" self._current_tgid_name = entry.get("tgtag") or ""
@@ -826,6 +879,8 @@ class MetadataWatcher:
self._started_at = started_at self._started_at = started_at
self._transmissions = max(1, transmissions) self._transmissions = max(1, transmissions)
self._audio_driven = audio_driven self._audio_driven = audio_driven
self._tx = []
self._note_tx(srcaddr, started_at)
# Console fallback assumes the transmission is still up; it learns # Console fallback assumes the transmission is still up; it learns
# otherwise from the next channel scan. # otherwise from the next channel scan.
@@ -876,6 +931,7 @@ class MetadataWatcher:
"ended_at": _iso(end_epoch), "ended_at": _iso(end_epoch),
"ended_at_epoch": end_epoch, "ended_at_epoch": end_epoch,
"transmissions": self._transmissions, "transmissions": self._transmissions,
"radios": self._radios(started_at if started_at is not None else end_epoch, end_epoch),
"end_reason": reason, "end_reason": reason,
"attributed": attributed, "attributed": attributed,
"driver": "audio" if self._audio_driven else "console", "driver": "audio" if self._audio_driven else "console",
@@ -910,6 +966,7 @@ class MetadataWatcher:
self._current_srcaddr = None self._current_srcaddr = None
self._started_at = None self._started_at = None
self._transmissions = 0 self._transmissions = 0
self._tx = []
self._tx_active = False self._tx_active = False
self._last_tx_end = None self._last_tx_end = None
self._audio_driven = False self._audio_driven = False
@@ -949,6 +1006,10 @@ class MetadataWatcher:
self._current_freq = found.freq self._current_freq = found.freq
if found.rid is not None and self._current_srcaddr is None: if found.rid is not None and self._current_srcaddr is None:
self._current_srcaddr = found.rid self._current_srcaddr = found.rid
if not self._tx:
# Only as the sole entry: the window's latest radio has no
# timing of its own, so it must not reorder real sightings.
self._note_tx(found.rid, start)
self._transmissions = max(self._transmissions, found.grants) self._transmissions = max(self._transmissions, found.grants)
return return
@@ -71,6 +71,10 @@ class UploadMeta:
system_id: Optional[str] = None system_id: Optional[str] = None
audio_start_epoch: Optional[float] = None audio_start_epoch: Optional[float] = None
audio_end_epoch: Optional[float] = None audio_end_epoch: Optional[float] = None
# JSON list of {radio, start, end} — every radio heard on the call
# (server-26#304). Sent here as well as on call_end because C2 starts the
# pipeline from /upload, before call_end arrives.
radios: Optional[str] = None
@dataclass @dataclass
@@ -108,6 +112,8 @@ def _form(meta: UploadMeta) -> Dict[str, str]:
form["audio_start_epoch"] = f"{meta.audio_start_epoch:.3f}" form["audio_start_epoch"] = f"{meta.audio_start_epoch:.3f}"
if meta.audio_end_epoch is not None: if meta.audio_end_epoch is not None:
form["audio_end_epoch"] = f"{meta.audio_end_epoch:.3f}" form["audio_end_epoch"] = f"{meta.audio_end_epoch:.3f}"
if meta.radios:
form["radios"] = meta.radios
return form return form
+12 -1
View File
@@ -1,4 +1,5 @@
import asyncio import asyncio
import json
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Optional from typing import Optional
@@ -9,7 +10,7 @@ from app.models import SystemConfig, normalize_sdr_pins, normalize_secondary_pri
from app.internal.logger import logger from app.internal.logger import logger
from app.internal.mqtt_manager import mqtt_manager from app.internal.mqtt_manager import mqtt_manager
from app.internal import credentials from app.internal import credentials
from app.internal.metadata_watcher import metadata_watcher from app.internal.metadata_watcher import metadata_watcher, rebase_radios
from app.internal.call_recorder import call_recorder from app.internal.call_recorder import call_recorder
from app.internal.discord_radio import radio_bot from app.internal.discord_radio import radio_bot
from app.internal.config_manager import ( from app.internal.config_manager import (
@@ -93,6 +94,15 @@ async def on_call_end(data: dict):
data["audio_end_at"] = _iso(recording.audio_end_epoch) data["audio_end_at"] = _iso(recording.audio_end_epoch)
data["audio_start_epoch"] = recording.audio_start_epoch data["audio_start_epoch"] = recording.audio_start_epoch
data["audio_end_epoch"] = recording.audio_end_epoch data["audio_end_epoch"] = recording.audio_end_epoch
# Radio offsets were measured from the call start; C2 maps an offset
# to wall clock as audio_start_epoch + t (above), so put them in the
# audio file's frame (server-26#304).
if data.get("radios") and data.get("started_at_epoch") is not None:
data["radios"] = rebase_radios(
data["radios"],
shift=data["started_at_epoch"] - recording.audio_start_epoch,
span=recording.audio_end_epoch - recording.audio_start_epoch,
)
data["audio_lead_trimmed"] = round(recording.lead_trimmed, 3) data["audio_lead_trimmed"] = round(recording.lead_trimmed, 3)
data["audio_tail_trimmed"] = round(recording.tail_trimmed, 3) data["audio_tail_trimmed"] = round(recording.tail_trimmed, 3)
if recording.clamped_seconds: if recording.clamped_seconds:
@@ -108,6 +118,7 @@ async def on_call_end(data: dict):
system_id=node_cfg.assigned_system_id, system_id=node_cfg.assigned_system_id,
audio_start_epoch=recording.audio_start_epoch, audio_start_epoch=recording.audio_start_epoch,
audio_end_epoch=recording.audio_end_epoch, audio_end_epoch=recording.audio_end_epoch,
radios=json.dumps(data["radios"]) if data.get("radios") else None,
) )
if audio_url: if audio_url:
data["audio_url"] = audio_url data["audio_url"] = audio_url
@@ -27,6 +27,7 @@ from app.internal.metadata_watcher import (
MAX_SEGMENT_SECONDS, MAX_SEGMENT_SECONDS,
MetadataWatcher, MetadataWatcher,
OP25_OFFLINE_GRACE, OP25_OFFLINE_GRACE,
rebase_radios,
) )
from app.internal.op25_client import TerminalUpdate, parse_terminal_messages from app.internal.op25_client import TerminalUpdate, parse_terminal_messages
@@ -1147,3 +1148,86 @@ async def test_console_history_is_bounded(hearing, clock, audio):
clock.advance(CONSOLE_HISTORY_SECONDS + 1) clock.advance(CONSOLE_HISTORY_SECONDS + 1)
await tick(hearing, update()) await tick(hearing, update())
assert all(e.epoch >= clock.now - CONSOLE_HISTORY_SECONDS for e in hearing._console) assert all(e.epoch >= clock.now - CONSOLE_HISTORY_SECONDS for e in hearing._console)
# ---------------------------------------------------------------------------
# Every radio on the call (server-26#304) — not just the last srcaddr
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_call_end_lists_every_radio_in_order(watcher, clock):
"""Dispatcher, unit, dispatcher on one talkgroup: three transmissions."""
start = clock.now
await tick(watcher, update(call_log=[grant(1234, start, rid=555)],
channels=[channel(tgid=1234, srcaddr=555)]))
clock.advance(1.0)
await tick(watcher, update(channels=[channel(tgid=1234, srcaddr=555)]))
clock.advance(1.0)
await tick(watcher, update(channels=[channel(tgid=1234, srcaddr=777)]))
clock.advance(0.5)
await tick(watcher, update(channels=[channel(tgid=1234, srcaddr=777)]))
clock.advance(1.0)
await tick(watcher, update(channels=[channel(tgid=1234, srcaddr=555)]))
clock.advance(0.5)
await tick(watcher, update(channels=[channel(tgid=1234, srcaddr=0, hold_tgid=1234)]))
clock.advance(settings.call_idle_timeout + 0.5)
await tick(watcher, update(channels=[channel()]))
payload = watcher.on_call_end.call_args[0][0]
assert [r["radio"] for r in payload["radios"]] == [555, 777, 555]
assert payload["radios"][0] == {"radio": 555, "start": 0.0, "end": 1.0}
assert payload["radios"][1] == {"radio": 777, "start": 2.0, "end": 2.5}
# srcaddr keeps its old meaning (the latest talker) for existing readers.
assert payload["srcaddr"] == 555
@pytest.mark.asyncio
async def test_missed_polls_inside_one_keyup_stay_one_transmission(watcher, clock):
"""The radio ID drops out of some polls mid-transmission; that is not a new key-up."""
await tick(watcher, update(call_log=[grant(1234, clock.now, rid=555)],
channels=[channel(tgid=1234, srcaddr=555)]))
clock.advance(0.5)
await tick(watcher, update(channels=[channel(tgid=1234, srcaddr=0, hold_tgid=1234)]))
clock.advance(1.0)
await tick(watcher, update(channels=[channel(tgid=1234, srcaddr=555)]))
clock.advance(0.5)
await tick(watcher, update(channels=[channel(tgid=1234, srcaddr=0, hold_tgid=1234)]))
clock.advance(settings.call_idle_timeout + 0.5)
await tick(watcher, update(channels=[channel()]))
radios = watcher.on_call_end.call_args[0][0]["radios"]
assert radios == [{"radio": 555, "start": 0.0, "end": 1.5}]
@pytest.mark.asyncio
async def test_radio_list_resets_between_calls(watcher, clock):
await tick(watcher, update(call_log=[grant(1234, clock.now, rid=555)]))
clock.advance(0.2)
await tick(watcher, update(call_log=[grant(4321, clock.now, rid=888)]))
first = watcher.on_call_end.call_args[0][0]
assert [r["radio"] for r in first["radios"]] == [555]
assert watcher._tx == [[888, clock.now, clock.now]]
@pytest.mark.asyncio
async def test_audio_mode_records_a_radio_seen_only_in_a_grant(hearing, clock, audio):
"""A short key-up between channel polls is only visible as a call_log grant."""
audio.speak()
await tick(hearing, update(call_log=[grant(1234, clock.now, rid=555)],
channels=[channel(tgid=1234, srcaddr=555)]))
clock.advance(1.0)
audio.speak()
await tick(hearing, update(call_log=[grant(1234, clock.now, rid=777)]))
clock.advance(settings.call_silence_timeout + 0.5)
await tick(hearing, update(channels=[channel()]))
radios = hearing.on_call_end.call_args[0][0]["radios"]
assert [r["radio"] for r in radios] == [555, 777]
def test_rebase_radios_moves_offsets_into_the_audio_frame():
"""The file starts 0.25 s of pre-roll before the call, then 1.0 s of lead was trimmed."""
radios = [{"radio": 555, "start": 0.0, "end": 2.0}, {"radio": 777, "start": 3.0, "end": 9.0}]
out = rebase_radios(radios, shift=-0.75, span=6.0)
assert out == [{"radio": 555, "start": 0.0, "end": 1.25}, {"radio": 777, "start": 2.25, "end": 6.0}]
+7
View File
@@ -228,3 +228,10 @@ async def test_wake_makes_everything_due_now(tmp_path):
sp.add(_recording(tmp_path), UploadMeta(call_id="c1"), UploadResult(RETRY, status=401)) sp.add(_recording(tmp_path), UploadMeta(call_id="c1"), UploadResult(RETRY, status=401))
sp.wake() sp.wake()
assert await sp.drain_once() == 1 assert await sp.drain_once() == 1
def test_form_carries_the_radio_list_and_omits_it_when_empty():
"""C2 starts correlation from /upload, so the radio list rides on it (server-26#304)."""
radios = json.dumps([{"radio": 555, "start": 0.0, "end": 1.0}])
assert upload_spool._form(UploadMeta(call_id="c1", radios=radios))["radios"] == radios
assert "radios" not in upload_spool._form(UploadMeta(call_id="c1"))