d6dfe5a293
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>
209 lines
7.3 KiB
Python
209 lines
7.3 KiB
Python
from fastapi import APIRouter, HTTPException, Body
|
|
from typing import Optional
|
|
import asyncio
|
|
import httpx
|
|
from app.config import settings
|
|
from app.models import SystemConfig
|
|
from app.internal.op25_client import op25_client
|
|
from app.internal.config_manager import load_node_config, save_node_config, apply_system_config
|
|
from app.internal.call_recorder import call_recorder
|
|
from app.internal.discord_radio import radio_bot
|
|
from app.internal.metadata_watcher import metadata_watcher
|
|
from app.internal import credentials
|
|
from app.internal.mqtt_manager import mqtt_manager
|
|
|
|
router = APIRouter(prefix="/api", tags=["api"])
|
|
|
|
|
|
@router.get("/status")
|
|
async def get_status():
|
|
node_cfg = load_node_config()
|
|
op25_status = await op25_client.status()
|
|
|
|
active_tgid = metadata_watcher.current_tgid
|
|
active_tgid_name = metadata_watcher.current_tgid_name
|
|
system_name = None
|
|
|
|
active_config = node_cfg.override_config if (node_cfg.override_system_id and node_cfg.override_config) else node_cfg.system_config
|
|
if active_config:
|
|
system_name = active_config.name
|
|
if active_tgid:
|
|
# Reverse engineer the TGID to talkgroup name based on active system object
|
|
tgs = active_config.config.get("talkgroups", [])
|
|
for tg in tgs:
|
|
if str(tg.get("id")) == str(active_tgid):
|
|
active_tgid_name = tg.get("name")
|
|
break
|
|
|
|
return {
|
|
"node_id": settings.node_id,
|
|
"node_name": settings.node_name,
|
|
"lat": settings.node_lat,
|
|
"lon": settings.node_lon,
|
|
"node_type": node_cfg.node_type,
|
|
"enforce_override_timeout": node_cfg.enforce_override_timeout,
|
|
"is_overridden": node_cfg.override_system_id is not None and node_cfg.node_type != "portable",
|
|
"override_system_id": node_cfg.override_system_id,
|
|
"configured": node_cfg.configured,
|
|
"assigned_system_id": node_cfg.assigned_system_id,
|
|
"system_name": system_name,
|
|
"is_recording": call_recorder.is_recording,
|
|
# Health of the PulseAudio capture that feeds every recording — the single
|
|
# most useful signal when recordings come back empty. Segment boundaries
|
|
# come from this stream, so audio_silence_seconds is also how far the
|
|
# node currently is from closing whatever it is recording.
|
|
"audio_capture": call_recorder.is_capturing,
|
|
"buffered_seconds": round(call_recorder.buffered_seconds, 1),
|
|
"audio_silence_seconds": round(call_recorder.audio_activity().silence_seconds, 1),
|
|
# Audio that was recorded but had no OP25 talkgroup anywhere near it, so
|
|
# it was discarded rather than uploaded. Non-zero means either the
|
|
# console is not decoding or something else is feeding drb_sink.
|
|
"unattributed_segments": metadata_watcher.unattributed_segments,
|
|
"active_tgid": active_tgid,
|
|
"active_tgid_name": active_tgid_name,
|
|
"active_call_id": metadata_watcher.active_call_id,
|
|
"discord_connected": radio_bot.is_connected,
|
|
"icecast_url": (
|
|
f"http://{settings.icecast_host}:{settings.icecast_port}{settings.icecast_mount}"
|
|
),
|
|
"op25": op25_status,
|
|
}
|
|
|
|
|
|
@router.post("/op25/start")
|
|
async def start_op25():
|
|
ok = await op25_client.start()
|
|
if not ok:
|
|
raise HTTPException(500, "Failed to start OP25")
|
|
return {"ok": True}
|
|
|
|
|
|
@router.post("/op25/stop")
|
|
async def stop_op25():
|
|
ok = await op25_client.stop()
|
|
if not ok:
|
|
raise HTTPException(500, "Failed to stop OP25")
|
|
return {"ok": True}
|
|
|
|
|
|
@router.get("/config")
|
|
async def get_config():
|
|
return load_node_config()
|
|
|
|
|
|
@router.post("/config/system")
|
|
async def set_system_config(config: SystemConfig):
|
|
"""
|
|
Apply a system config locally — called by the web UI or pushed by C2.
|
|
Writes the OP25 config and persists the node config.
|
|
"""
|
|
node_cfg = load_node_config()
|
|
node_cfg.assigned_system_id = config.system_id
|
|
node_cfg.system_config = config
|
|
node_cfg.configured = True
|
|
save_node_config(node_cfg)
|
|
apply_system_config(config)
|
|
return {"ok": True}
|
|
|
|
|
|
@router.get("/systems")
|
|
async def get_systems():
|
|
from app.internal.system_cacher import load_cached_systems, fetch_and_cache_systems
|
|
asyncio.create_task(fetch_and_cache_systems())
|
|
return load_cached_systems()
|
|
|
|
|
|
@router.post("/config/override")
|
|
async def set_override(
|
|
system_id: Optional[str] = Body(None),
|
|
system_config: Optional[dict] = Body(None)
|
|
):
|
|
node_cfg = load_node_config()
|
|
config = None
|
|
|
|
if system_id:
|
|
from app.internal.system_cacher import get_cached_system
|
|
cached = get_cached_system(system_id)
|
|
if not cached:
|
|
raise HTTPException(404, f"System '{system_id}' not found in cache.")
|
|
config = SystemConfig(**cached)
|
|
elif system_config:
|
|
if not all(k in system_config for k in ("system_id", "name", "type", "config")):
|
|
raise HTTPException(400, "Invalid manual system config schema.")
|
|
config = SystemConfig(**system_config)
|
|
else:
|
|
raise HTTPException(400, "Must specify system_id or system_config.")
|
|
|
|
node_cfg.override_system_id = config.system_id
|
|
node_cfg.override_config = config
|
|
save_node_config(node_cfg)
|
|
|
|
from app.main import _generate_op25_config
|
|
if not await _generate_op25_config(config):
|
|
raise HTTPException(500, f"Failed to generate OP25 config for override: {config.name}")
|
|
|
|
await op25_client.stop()
|
|
await asyncio.sleep(2)
|
|
await op25_client.start()
|
|
|
|
await mqtt_manager._publish_checkin()
|
|
return {"ok": True}
|
|
|
|
|
|
@router.post("/config/revert")
|
|
async def revert_config():
|
|
node_cfg = load_node_config()
|
|
if not node_cfg.override_system_id:
|
|
return {"ok": True, "message": "No override active."}
|
|
|
|
node_cfg.override_system_id = None
|
|
node_cfg.override_config = None
|
|
save_node_config(node_cfg)
|
|
|
|
if node_cfg.system_config:
|
|
from app.main import _generate_op25_config
|
|
if not await _generate_op25_config(node_cfg.system_config):
|
|
raise HTTPException(500, "Failed to regenerate original OP25 config.")
|
|
|
|
await op25_client.stop()
|
|
await asyncio.sleep(2)
|
|
await op25_client.start()
|
|
|
|
await mqtt_manager._publish_checkin()
|
|
return {"ok": True}
|
|
|
|
|
|
@router.post("/config/override/ack")
|
|
async def ack_override(timeout_minutes: int = Body(1440)):
|
|
if not settings.c2_url:
|
|
raise HTTPException(400, "C2_URL not configured.")
|
|
|
|
api_key = credentials.get_api_key()
|
|
headers = {"Authorization": f"Bearer {api_key}"} if api_key else {}
|
|
|
|
try:
|
|
async with httpx.AsyncClient(timeout=10) as client:
|
|
r = await client.post(
|
|
f"{settings.c2_url}/nodes/{settings.node_id}/override/ack",
|
|
json={"timeout_minutes": timeout_minutes},
|
|
headers=headers
|
|
)
|
|
r.raise_for_status()
|
|
return r.json()
|
|
except Exception as e:
|
|
raise HTTPException(500, f"Failed to contact C2: {e}")
|
|
|
|
|
|
@router.post("/discord/join")
|
|
async def discord_join(guild_id: int, channel_id: int):
|
|
ok = await radio_bot.join(guild_id, channel_id)
|
|
if not ok:
|
|
raise HTTPException(500, "Failed to join voice channel")
|
|
return {"ok": True}
|
|
|
|
|
|
@router.post("/discord/leave")
|
|
async def discord_leave():
|
|
await radio_bot.leave()
|
|
return {"ok": True}
|