mirror of
https://github.com/smittix/intercept.git
synced 2026-06-08 14:11:54 -07:00
Add real-time signal scope to pager mode
Tap the rtl_fm → multimon-ng audio pipeline via a relay thread to extract RMS/peak amplitude levels and render a 60fps canvas oscilloscope during pager decoding, giving visual feedback of RF activity before messages are fully decoded. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
+90
-12
@@ -2,12 +2,14 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import os
|
||||
import pathlib
|
||||
import re
|
||||
import pty
|
||||
import queue
|
||||
import select
|
||||
import struct
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
@@ -22,8 +24,8 @@ from utils.validation import (
|
||||
validate_frequency, validate_device_index, validate_gain, validate_ppm,
|
||||
validate_rtl_tcp_host, validate_rtl_tcp_port
|
||||
)
|
||||
from utils.sse import format_sse
|
||||
from utils.event_pipeline import process_event
|
||||
from utils.sse import format_sse
|
||||
from utils.event_pipeline import process_event
|
||||
from utils.process import safe_terminate, register_process, unregister_process
|
||||
from utils.sdr import SDRFactory, SDRType, SDRValidationError
|
||||
from utils.dependencies import get_tool_path
|
||||
@@ -106,6 +108,62 @@ def log_message(msg: dict[str, Any]) -> None:
|
||||
logger.error(f"Failed to log message: {e}")
|
||||
|
||||
|
||||
def audio_relay_thread(
|
||||
rtl_stdout,
|
||||
multimon_stdin,
|
||||
output_queue: queue.Queue,
|
||||
stop_event: threading.Event,
|
||||
) -> None:
|
||||
"""Relay audio from rtl_fm to multimon-ng while computing signal levels.
|
||||
|
||||
Reads raw 16-bit LE PCM from *rtl_stdout*, writes every chunk straight
|
||||
through to *multimon_stdin*, and every ~100 ms pushes an RMS / peak scope
|
||||
event onto *output_queue*.
|
||||
"""
|
||||
CHUNK = 4096 # bytes – 2048 samples at 16-bit mono
|
||||
INTERVAL = 0.1 # seconds between scope updates
|
||||
last_scope = time.monotonic()
|
||||
|
||||
try:
|
||||
while not stop_event.is_set():
|
||||
data = rtl_stdout.read(CHUNK)
|
||||
if not data:
|
||||
break
|
||||
|
||||
# Forward audio untouched
|
||||
try:
|
||||
multimon_stdin.write(data)
|
||||
multimon_stdin.flush()
|
||||
except (BrokenPipeError, OSError):
|
||||
break
|
||||
|
||||
# Compute scope levels every ~100 ms
|
||||
now = time.monotonic()
|
||||
if now - last_scope >= INTERVAL:
|
||||
last_scope = now
|
||||
try:
|
||||
n_samples = len(data) // 2
|
||||
if n_samples == 0:
|
||||
continue
|
||||
samples = struct.unpack(f'<{n_samples}h', data[:n_samples * 2])
|
||||
peak = max(abs(s) for s in samples)
|
||||
rms = int(math.sqrt(sum(s * s for s in samples) / n_samples))
|
||||
output_queue.put_nowait({
|
||||
'type': 'scope',
|
||||
'rms': rms,
|
||||
'peak': peak,
|
||||
})
|
||||
except (struct.error, ValueError, queue.Full):
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug(f"Audio relay error: {e}")
|
||||
finally:
|
||||
try:
|
||||
multimon_stdin.close()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def stream_decoder(master_fd: int, process: subprocess.Popen[bytes]) -> None:
|
||||
"""Stream decoder output to queue using PTY for unbuffered output."""
|
||||
try:
|
||||
@@ -152,6 +210,11 @@ def stream_decoder(master_fd: int, process: subprocess.Popen[bytes]) -> None:
|
||||
os.close(master_fd)
|
||||
except OSError:
|
||||
pass
|
||||
# Signal relay thread to stop
|
||||
with app_module.process_lock:
|
||||
stop_relay = getattr(app_module.current_process, '_stop_relay', None)
|
||||
if stop_relay:
|
||||
stop_relay.set()
|
||||
# Cleanup companion rtl_fm process and decoder
|
||||
with app_module.process_lock:
|
||||
rtl_proc = getattr(app_module.current_process, '_rtl_process', None)
|
||||
@@ -319,7 +382,7 @@ def start_decoding() -> Response:
|
||||
|
||||
multimon_process = subprocess.Popen(
|
||||
multimon_cmd,
|
||||
stdin=rtl_process.stdout,
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=slave_fd,
|
||||
stderr=slave_fd,
|
||||
close_fds=True
|
||||
@@ -327,11 +390,22 @@ def start_decoding() -> Response:
|
||||
register_process(multimon_process)
|
||||
|
||||
os.close(slave_fd)
|
||||
rtl_process.stdout.close()
|
||||
|
||||
# Spawn audio relay thread between rtl_fm and multimon-ng
|
||||
stop_relay = threading.Event()
|
||||
relay = threading.Thread(
|
||||
target=audio_relay_thread,
|
||||
args=(rtl_process.stdout, multimon_process.stdin,
|
||||
app_module.output_queue, stop_relay),
|
||||
)
|
||||
relay.daemon = True
|
||||
relay.start()
|
||||
|
||||
app_module.current_process = multimon_process
|
||||
app_module.current_process._rtl_process = rtl_process
|
||||
app_module.current_process._master_fd = master_fd
|
||||
app_module.current_process._stop_relay = stop_relay
|
||||
app_module.current_process._relay_thread = relay
|
||||
|
||||
# Start output thread with PTY master fd
|
||||
thread = threading.Thread(target=stream_decoder, args=(master_fd, multimon_process))
|
||||
@@ -380,6 +454,10 @@ def stop_decoding() -> Response:
|
||||
|
||||
with app_module.process_lock:
|
||||
if app_module.current_process:
|
||||
# Signal audio relay thread to stop
|
||||
if hasattr(app_module.current_process, '_stop_relay'):
|
||||
app_module.current_process._stop_relay.set()
|
||||
|
||||
# Kill rtl_fm process first
|
||||
if hasattr(app_module.current_process, '_rtl_process'):
|
||||
try:
|
||||
@@ -469,14 +547,14 @@ def stream() -> Response:
|
||||
keepalive_interval = 30.0 # Send keepalive every 30 seconds instead of 1 second
|
||||
|
||||
while True:
|
||||
try:
|
||||
msg = app_module.output_queue.get(timeout=1)
|
||||
last_keepalive = time.time()
|
||||
try:
|
||||
process_event('pager', msg, msg.get('type'))
|
||||
except Exception:
|
||||
pass
|
||||
yield format_sse(msg)
|
||||
try:
|
||||
msg = app_module.output_queue.get(timeout=1)
|
||||
last_keepalive = time.time()
|
||||
try:
|
||||
process_event('pager', msg, msg.get('type'))
|
||||
except Exception:
|
||||
pass
|
||||
yield format_sse(msg)
|
||||
except queue.Empty:
|
||||
now = time.time()
|
||||
if now - last_keepalive >= keepalive_interval:
|
||||
|
||||
@@ -2204,6 +2204,21 @@
|
||||
<div id="pagerTimelineContainer" style="display: none; margin-bottom: 12px;"></div>
|
||||
<div id="sensorTimelineContainer" style="display: none; margin-bottom: 12px;"></div>
|
||||
|
||||
<!-- Pager Signal Scope -->
|
||||
<div id="pagerScopePanel" style="display: none; margin-bottom: 12px;">
|
||||
<div style="background: #0a0a0a; border: 1px solid #1a1a2e; border-radius: 6px; padding: 8px 10px; font-family: 'JetBrains Mono', 'Fira Code', monospace;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; font-size: 10px; color: #555; text-transform: uppercase; letter-spacing: 1px;">
|
||||
<span>Signal Scope</span>
|
||||
<div style="display: flex; gap: 14px;">
|
||||
<span>RMS: <span id="scopeRmsLabel" style="color: #0ff; font-variant-numeric: tabular-nums;">0</span></span>
|
||||
<span>PEAK: <span id="scopePeakLabel" style="color: #f44; font-variant-numeric: tabular-nums;">0</span></span>
|
||||
<span id="scopeStatusLabel" style="color: #444;">IDLE</span>
|
||||
</div>
|
||||
</div>
|
||||
<canvas id="pagerScopeCanvas" style="width: 100%; height: 80px; display: block; border-radius: 3px; background: #050510;"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="output-content signal-feed" id="output">
|
||||
<div class="placeholder signal-empty-state">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
@@ -4265,6 +4280,153 @@
|
||||
// Pager mode polling timer for agent mode
|
||||
let pagerPollTimer = null;
|
||||
|
||||
// --- Pager Signal Scope ---
|
||||
let pagerScopeCtx = null;
|
||||
let pagerScopeAnim = null;
|
||||
let pagerScopeHistory = [];
|
||||
const SCOPE_HISTORY_LEN = 200;
|
||||
let pagerScopeRms = 0;
|
||||
let pagerScopePeak = 0;
|
||||
let pagerScopeTargetRms = 0;
|
||||
let pagerScopeTargetPeak = 0;
|
||||
let pagerScopeMsgBurst = 0;
|
||||
|
||||
function initPagerScope() {
|
||||
const canvas = document.getElementById('pagerScopeCanvas');
|
||||
if (!canvas) return;
|
||||
// Set actual pixel resolution
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
canvas.width = rect.width * (window.devicePixelRatio || 1);
|
||||
canvas.height = rect.height * (window.devicePixelRatio || 1);
|
||||
pagerScopeCtx = canvas.getContext('2d');
|
||||
pagerScopeHistory = new Array(SCOPE_HISTORY_LEN).fill(0);
|
||||
pagerScopeRms = 0;
|
||||
pagerScopePeak = 0;
|
||||
pagerScopeTargetRms = 0;
|
||||
pagerScopeTargetPeak = 0;
|
||||
pagerScopeMsgBurst = 0;
|
||||
drawPagerScope();
|
||||
}
|
||||
|
||||
function drawPagerScope() {
|
||||
const ctx = pagerScopeCtx;
|
||||
if (!ctx) return;
|
||||
const W = ctx.canvas.width;
|
||||
const H = ctx.canvas.height;
|
||||
const midY = H / 2;
|
||||
|
||||
// Phosphor persistence: semi-transparent clear
|
||||
ctx.fillStyle = 'rgba(5, 5, 16, 0.3)';
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
|
||||
// Smooth towards target values
|
||||
pagerScopeRms += (pagerScopeTargetRms - pagerScopeRms) * 0.25;
|
||||
pagerScopePeak += (pagerScopeTargetPeak - pagerScopePeak) * 0.15;
|
||||
|
||||
// Push current RMS into history (normalized 0-1 against 32768)
|
||||
pagerScopeHistory.push(Math.min(pagerScopeRms / 32768, 1.0));
|
||||
if (pagerScopeHistory.length > SCOPE_HISTORY_LEN) {
|
||||
pagerScopeHistory.shift();
|
||||
}
|
||||
|
||||
// Grid lines
|
||||
ctx.strokeStyle = 'rgba(40, 40, 80, 0.4)';
|
||||
ctx.lineWidth = 1;
|
||||
for (let g = 0.25; g < 1; g += 0.25) {
|
||||
const gy = midY - g * midY;
|
||||
const gy2 = midY + g * midY;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, gy); ctx.lineTo(W, gy);
|
||||
ctx.moveTo(0, gy2); ctx.lineTo(W, gy2);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// Center baseline
|
||||
ctx.strokeStyle = 'rgba(60, 60, 100, 0.5)';
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, midY);
|
||||
ctx.lineTo(W, midY);
|
||||
ctx.stroke();
|
||||
|
||||
// Waveform (mirrored)
|
||||
const stepX = W / SCOPE_HISTORY_LEN;
|
||||
ctx.strokeStyle = '#0ff';
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.shadowColor = '#0ff';
|
||||
ctx.shadowBlur = 4;
|
||||
|
||||
// Upper half
|
||||
ctx.beginPath();
|
||||
for (let i = 0; i < pagerScopeHistory.length; i++) {
|
||||
const x = i * stepX;
|
||||
const amp = pagerScopeHistory[i] * midY * 0.9;
|
||||
const y = midY - amp;
|
||||
if (i === 0) ctx.moveTo(x, y);
|
||||
else ctx.lineTo(x, y);
|
||||
}
|
||||
ctx.stroke();
|
||||
|
||||
// Lower half (mirror)
|
||||
ctx.beginPath();
|
||||
for (let i = 0; i < pagerScopeHistory.length; i++) {
|
||||
const x = i * stepX;
|
||||
const amp = pagerScopeHistory[i] * midY * 0.9;
|
||||
const y = midY + amp;
|
||||
if (i === 0) ctx.moveTo(x, y);
|
||||
else ctx.lineTo(x, y);
|
||||
}
|
||||
ctx.stroke();
|
||||
|
||||
ctx.shadowBlur = 0;
|
||||
|
||||
// Peak indicator (dashed red line)
|
||||
const peakNorm = Math.min(pagerScopePeak / 32768, 1.0);
|
||||
if (peakNorm > 0.01) {
|
||||
const peakY = midY - peakNorm * midY * 0.9;
|
||||
ctx.strokeStyle = 'rgba(255, 68, 68, 0.6)';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.setLineDash([4, 4]);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, peakY);
|
||||
ctx.lineTo(W, peakY);
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
}
|
||||
|
||||
// Message decode flash (green overlay)
|
||||
if (pagerScopeMsgBurst > 0.01) {
|
||||
ctx.fillStyle = `rgba(0, 255, 100, ${pagerScopeMsgBurst * 0.15})`;
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
pagerScopeMsgBurst *= 0.88;
|
||||
}
|
||||
|
||||
// Update labels
|
||||
const rmsLabel = document.getElementById('scopeRmsLabel');
|
||||
const peakLabel = document.getElementById('scopePeakLabel');
|
||||
const statusLabel = document.getElementById('scopeStatusLabel');
|
||||
if (rmsLabel) rmsLabel.textContent = Math.round(pagerScopeRms);
|
||||
if (peakLabel) peakLabel.textContent = Math.round(pagerScopePeak);
|
||||
if (statusLabel) {
|
||||
if (pagerScopeRms > 500) {
|
||||
statusLabel.textContent = 'SIGNAL';
|
||||
statusLabel.style.color = '#0f0';
|
||||
} else {
|
||||
statusLabel.textContent = 'MONITORING';
|
||||
statusLabel.style.color = '#555';
|
||||
}
|
||||
}
|
||||
|
||||
pagerScopeAnim = requestAnimationFrame(drawPagerScope);
|
||||
}
|
||||
|
||||
function stopPagerScope() {
|
||||
if (pagerScopeAnim) {
|
||||
cancelAnimationFrame(pagerScopeAnim);
|
||||
pagerScopeAnim = null;
|
||||
}
|
||||
pagerScopeCtx = null;
|
||||
}
|
||||
|
||||
function startDecoding() {
|
||||
const freq = document.getElementById('frequency').value;
|
||||
const gain = document.getElementById('gain').value;
|
||||
@@ -4444,6 +4606,18 @@
|
||||
document.getElementById('statusText').textContent = running ? 'Decoding...' : 'Idle';
|
||||
document.getElementById('startBtn').style.display = running ? 'none' : 'block';
|
||||
document.getElementById('stopBtn').style.display = running ? 'block' : 'none';
|
||||
|
||||
// Signal scope
|
||||
const scopePanel = document.getElementById('pagerScopePanel');
|
||||
if (scopePanel) {
|
||||
if (running) {
|
||||
scopePanel.style.display = 'block';
|
||||
initPagerScope();
|
||||
} else {
|
||||
stopPagerScope();
|
||||
scopePanel.style.display = 'none';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function startStream(isAgentMode = false) {
|
||||
@@ -4479,6 +4653,9 @@
|
||||
}
|
||||
} else if (payload.type === 'info') {
|
||||
showInfo(`[${data.agent_name}] ${payload.text}`);
|
||||
} else if (payload.type === 'scope') {
|
||||
pagerScopeTargetRms = payload.rms;
|
||||
pagerScopeTargetPeak = payload.peak;
|
||||
}
|
||||
} else if (data.type === 'keepalive') {
|
||||
// Ignore keepalive messages
|
||||
@@ -4497,6 +4674,9 @@
|
||||
showInfo(data.text);
|
||||
} else if (data.type === 'raw') {
|
||||
showInfo(data.text);
|
||||
} else if (data.type === 'scope') {
|
||||
pagerScopeTargetRms = data.rms;
|
||||
pagerScopeTargetPeak = data.peak;
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -4604,6 +4784,9 @@
|
||||
// Update signal meter
|
||||
pulseSignal();
|
||||
|
||||
// Flash signal scope green on decode
|
||||
pagerScopeMsgBurst = 1.0;
|
||||
|
||||
// Use SignalCards component to create the message card (auto-detects status)
|
||||
const msgEl = SignalCards.createPagerCard(msg);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user