mirror of
https://github.com/smittix/intercept.git
synced 2026-07-11 19:18:13 -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
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
import os
|
import os
|
||||||
import pathlib
|
import pathlib
|
||||||
import re
|
import re
|
||||||
import pty
|
import pty
|
||||||
import queue
|
import queue
|
||||||
import select
|
import select
|
||||||
|
import struct
|
||||||
import subprocess
|
import subprocess
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
@@ -22,8 +24,8 @@ from utils.validation import (
|
|||||||
validate_frequency, validate_device_index, validate_gain, validate_ppm,
|
validate_frequency, validate_device_index, validate_gain, validate_ppm,
|
||||||
validate_rtl_tcp_host, validate_rtl_tcp_port
|
validate_rtl_tcp_host, validate_rtl_tcp_port
|
||||||
)
|
)
|
||||||
from utils.sse import format_sse
|
from utils.sse import format_sse
|
||||||
from utils.event_pipeline import process_event
|
from utils.event_pipeline import process_event
|
||||||
from utils.process import safe_terminate, register_process, unregister_process
|
from utils.process import safe_terminate, register_process, unregister_process
|
||||||
from utils.sdr import SDRFactory, SDRType, SDRValidationError
|
from utils.sdr import SDRFactory, SDRType, SDRValidationError
|
||||||
from utils.dependencies import get_tool_path
|
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}")
|
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:
|
def stream_decoder(master_fd: int, process: subprocess.Popen[bytes]) -> None:
|
||||||
"""Stream decoder output to queue using PTY for unbuffered output."""
|
"""Stream decoder output to queue using PTY for unbuffered output."""
|
||||||
try:
|
try:
|
||||||
@@ -152,6 +210,11 @@ def stream_decoder(master_fd: int, process: subprocess.Popen[bytes]) -> None:
|
|||||||
os.close(master_fd)
|
os.close(master_fd)
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
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
|
# Cleanup companion rtl_fm process and decoder
|
||||||
with app_module.process_lock:
|
with app_module.process_lock:
|
||||||
rtl_proc = getattr(app_module.current_process, '_rtl_process', None)
|
rtl_proc = getattr(app_module.current_process, '_rtl_process', None)
|
||||||
@@ -319,7 +382,7 @@ def start_decoding() -> Response:
|
|||||||
|
|
||||||
multimon_process = subprocess.Popen(
|
multimon_process = subprocess.Popen(
|
||||||
multimon_cmd,
|
multimon_cmd,
|
||||||
stdin=rtl_process.stdout,
|
stdin=subprocess.PIPE,
|
||||||
stdout=slave_fd,
|
stdout=slave_fd,
|
||||||
stderr=slave_fd,
|
stderr=slave_fd,
|
||||||
close_fds=True
|
close_fds=True
|
||||||
@@ -327,11 +390,22 @@ def start_decoding() -> Response:
|
|||||||
register_process(multimon_process)
|
register_process(multimon_process)
|
||||||
|
|
||||||
os.close(slave_fd)
|
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 = multimon_process
|
||||||
app_module.current_process._rtl_process = rtl_process
|
app_module.current_process._rtl_process = rtl_process
|
||||||
app_module.current_process._master_fd = master_fd
|
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
|
# Start output thread with PTY master fd
|
||||||
thread = threading.Thread(target=stream_decoder, args=(master_fd, multimon_process))
|
thread = threading.Thread(target=stream_decoder, args=(master_fd, multimon_process))
|
||||||
@@ -380,6 +454,10 @@ def stop_decoding() -> Response:
|
|||||||
|
|
||||||
with app_module.process_lock:
|
with app_module.process_lock:
|
||||||
if app_module.current_process:
|
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
|
# Kill rtl_fm process first
|
||||||
if hasattr(app_module.current_process, '_rtl_process'):
|
if hasattr(app_module.current_process, '_rtl_process'):
|
||||||
try:
|
try:
|
||||||
@@ -469,14 +547,14 @@ def stream() -> Response:
|
|||||||
keepalive_interval = 30.0 # Send keepalive every 30 seconds instead of 1 second
|
keepalive_interval = 30.0 # Send keepalive every 30 seconds instead of 1 second
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
msg = app_module.output_queue.get(timeout=1)
|
msg = app_module.output_queue.get(timeout=1)
|
||||||
last_keepalive = time.time()
|
last_keepalive = time.time()
|
||||||
try:
|
try:
|
||||||
process_event('pager', msg, msg.get('type'))
|
process_event('pager', msg, msg.get('type'))
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
yield format_sse(msg)
|
yield format_sse(msg)
|
||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
now = time.time()
|
now = time.time()
|
||||||
if now - last_keepalive >= keepalive_interval:
|
if now - last_keepalive >= keepalive_interval:
|
||||||
|
|||||||
@@ -2204,6 +2204,21 @@
|
|||||||
<div id="pagerTimelineContainer" style="display: none; margin-bottom: 12px;"></div>
|
<div id="pagerTimelineContainer" style="display: none; margin-bottom: 12px;"></div>
|
||||||
<div id="sensorTimelineContainer" 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="output-content signal-feed" id="output">
|
||||||
<div class="placeholder signal-empty-state">
|
<div class="placeholder signal-empty-state">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
<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
|
// Pager mode polling timer for agent mode
|
||||||
let pagerPollTimer = null;
|
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() {
|
function startDecoding() {
|
||||||
const freq = document.getElementById('frequency').value;
|
const freq = document.getElementById('frequency').value;
|
||||||
const gain = document.getElementById('gain').value;
|
const gain = document.getElementById('gain').value;
|
||||||
@@ -4444,6 +4606,18 @@
|
|||||||
document.getElementById('statusText').textContent = running ? 'Decoding...' : 'Idle';
|
document.getElementById('statusText').textContent = running ? 'Decoding...' : 'Idle';
|
||||||
document.getElementById('startBtn').style.display = running ? 'none' : 'block';
|
document.getElementById('startBtn').style.display = running ? 'none' : 'block';
|
||||||
document.getElementById('stopBtn').style.display = running ? 'block' : 'none';
|
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) {
|
function startStream(isAgentMode = false) {
|
||||||
@@ -4479,6 +4653,9 @@
|
|||||||
}
|
}
|
||||||
} else if (payload.type === 'info') {
|
} else if (payload.type === 'info') {
|
||||||
showInfo(`[${data.agent_name}] ${payload.text}`);
|
showInfo(`[${data.agent_name}] ${payload.text}`);
|
||||||
|
} else if (payload.type === 'scope') {
|
||||||
|
pagerScopeTargetRms = payload.rms;
|
||||||
|
pagerScopeTargetPeak = payload.peak;
|
||||||
}
|
}
|
||||||
} else if (data.type === 'keepalive') {
|
} else if (data.type === 'keepalive') {
|
||||||
// Ignore keepalive messages
|
// Ignore keepalive messages
|
||||||
@@ -4497,6 +4674,9 @@
|
|||||||
showInfo(data.text);
|
showInfo(data.text);
|
||||||
} else if (data.type === 'raw') {
|
} else if (data.type === 'raw') {
|
||||||
showInfo(data.text);
|
showInfo(data.text);
|
||||||
|
} else if (data.type === 'scope') {
|
||||||
|
pagerScopeTargetRms = data.rms;
|
||||||
|
pagerScopeTargetPeak = data.peak;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -4604,6 +4784,9 @@
|
|||||||
// Update signal meter
|
// Update signal meter
|
||||||
pulseSignal();
|
pulseSignal();
|
||||||
|
|
||||||
|
// Flash signal scope green on decode
|
||||||
|
pagerScopeMsgBurst = 1.0;
|
||||||
|
|
||||||
// Use SignalCards component to create the message card (auto-detects status)
|
// Use SignalCards component to create the message card (auto-detects status)
|
||||||
const msgEl = SignalCards.createPagerCard(msg);
|
const msgEl = SignalCards.createPagerCard(msg);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user