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:
Smittix
2026-02-07 23:50:41 +00:00
parent 63b8575201
commit 2fa7e8b6c8
2 changed files with 273 additions and 12 deletions
+90 -12
View File
@@ -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:
+183
View File
@@ -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);