mirror of
https://github.com/smittix/intercept.git
synced 2026-04-24 06:40:00 -07:00
Add audio alerts, export options, signal meter, and UI enhancements
- Audio alerts on new messages with mute toggle (persisted in localStorage) - CSV and JSON export for captured messages - Signal strength meter showing message activity - Waterfall display with activity visualization - Auto-scroll toggle (persisted in localStorage) - Relative timestamps (e.g., "5s ago", "2m ago") - Visual feedback on control buttons (active/muted states) - Flash effect on waterfall canvas when messages arrive 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
340
intercept.py
340
intercept.py
@@ -561,6 +561,89 @@ HTML_TEMPLATE = '''
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.header-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.signal-meter {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 2px;
|
||||
height: 20px;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.signal-bar {
|
||||
width: 4px;
|
||||
background: var(--border-color);
|
||||
transition: all 0.1s ease;
|
||||
}
|
||||
|
||||
.signal-bar:nth-child(1) { height: 4px; }
|
||||
.signal-bar:nth-child(2) { height: 8px; }
|
||||
.signal-bar:nth-child(3) { height: 12px; }
|
||||
.signal-bar:nth-child(4) { height: 16px; }
|
||||
.signal-bar:nth-child(5) { height: 20px; }
|
||||
|
||||
.signal-bar.active {
|
||||
background: var(--accent-cyan);
|
||||
box-shadow: 0 0 8px var(--accent-cyan);
|
||||
}
|
||||
|
||||
.waterfall-container {
|
||||
padding: 0 15px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
#waterfallCanvas {
|
||||
width: 100%;
|
||||
height: 60px;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
transition: box-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
#waterfallCanvas.active {
|
||||
box-shadow: 0 0 15px var(--accent-cyan-dim);
|
||||
border-color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.status-controls {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.control-btn {
|
||||
padding: 6px 12px;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
transition: all 0.2s ease;
|
||||
font-family: 'Rajdhani', sans-serif;
|
||||
}
|
||||
|
||||
.control-btn:hover {
|
||||
border-color: var(--accent-cyan);
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.control-btn.active {
|
||||
border-color: var(--accent-green);
|
||||
color: var(--accent-green);
|
||||
}
|
||||
|
||||
.control-btn.muted {
|
||||
border-color: var(--accent-red);
|
||||
color: var(--accent-red);
|
||||
}
|
||||
|
||||
/* Scanline effect overlay */
|
||||
body::before {
|
||||
content: '';
|
||||
@@ -699,13 +782,26 @@ HTML_TEMPLATE = '''
|
||||
<div class="output-panel">
|
||||
<div class="output-header">
|
||||
<h3>Decoded Messages</h3>
|
||||
<div class="stats">
|
||||
<div>Messages: <span id="msgCount">0</span></div>
|
||||
<div>POCSAG: <span id="pocsagCount">0</span></div>
|
||||
<div>FLEX: <span id="flexCount">0</span></div>
|
||||
<div class="header-controls">
|
||||
<div id="signalMeter" class="signal-meter" title="Signal Activity">
|
||||
<div class="signal-bar"></div>
|
||||
<div class="signal-bar"></div>
|
||||
<div class="signal-bar"></div>
|
||||
<div class="signal-bar"></div>
|
||||
<div class="signal-bar"></div>
|
||||
</div>
|
||||
<div class="stats">
|
||||
<div>MSG: <span id="msgCount">0</span></div>
|
||||
<div>POCSAG: <span id="pocsagCount">0</span></div>
|
||||
<div>FLEX: <span id="flexCount">0</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="waterfall-container">
|
||||
<canvas id="waterfallCanvas" width="800" height="60"></canvas>
|
||||
</div>
|
||||
|
||||
<div class="output-content" id="output">
|
||||
<div class="placeholder" style="color: #888; text-align: center; padding: 50px;">
|
||||
Configure settings and click "Start Decoding" to begin.
|
||||
@@ -717,7 +813,13 @@ HTML_TEMPLATE = '''
|
||||
<div class="status-dot" id="statusDot"></div>
|
||||
<span id="statusText">Idle</span>
|
||||
</div>
|
||||
<button class="clear-btn" onclick="clearMessages()">Clear Messages</button>
|
||||
<div class="status-controls">
|
||||
<button id="muteBtn" class="control-btn" onclick="toggleMute()">🔊 MUTE</button>
|
||||
<button id="autoScrollBtn" class="control-btn" onclick="toggleAutoScroll()">⬇ AUTO-SCROLL ON</button>
|
||||
<button class="control-btn" onclick="exportCSV()">📄 CSV</button>
|
||||
<button class="control-btn" onclick="exportJSON()">📋 JSON</button>
|
||||
<button class="clear-btn" onclick="clearMessages()">Clear</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -731,6 +833,202 @@ HTML_TEMPLATE = '''
|
||||
let flexCount = 0;
|
||||
let deviceList = {{ devices | tojson | safe }};
|
||||
|
||||
// Audio alert settings
|
||||
let audioMuted = localStorage.getItem('audioMuted') === 'true';
|
||||
let audioContext = null;
|
||||
|
||||
function initAudio() {
|
||||
if (!audioContext) {
|
||||
audioContext = new (window.AudioContext || window.webkitAudioContext)();
|
||||
}
|
||||
}
|
||||
|
||||
function playAlert() {
|
||||
if (audioMuted || !audioContext) return;
|
||||
const oscillator = audioContext.createOscillator();
|
||||
const gainNode = audioContext.createGain();
|
||||
oscillator.connect(gainNode);
|
||||
gainNode.connect(audioContext.destination);
|
||||
oscillator.frequency.value = 880;
|
||||
oscillator.type = 'sine';
|
||||
gainNode.gain.setValueAtTime(0.3, audioContext.currentTime);
|
||||
gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.2);
|
||||
oscillator.start(audioContext.currentTime);
|
||||
oscillator.stop(audioContext.currentTime + 0.2);
|
||||
}
|
||||
|
||||
function toggleMute() {
|
||||
audioMuted = !audioMuted;
|
||||
localStorage.setItem('audioMuted', audioMuted);
|
||||
updateMuteButton();
|
||||
}
|
||||
|
||||
function updateMuteButton() {
|
||||
const btn = document.getElementById('muteBtn');
|
||||
if (btn) {
|
||||
btn.innerHTML = audioMuted ? '🔇 UNMUTE' : '🔊 MUTE';
|
||||
btn.classList.toggle('muted', audioMuted);
|
||||
}
|
||||
}
|
||||
|
||||
// Message storage for export
|
||||
let allMessages = [];
|
||||
|
||||
function exportCSV() {
|
||||
if (allMessages.length === 0) {
|
||||
alert('No messages to export');
|
||||
return;
|
||||
}
|
||||
const headers = ['Timestamp', 'Protocol', 'Address', 'Function', 'Type', 'Message'];
|
||||
const csv = [headers.join(',')];
|
||||
allMessages.forEach(msg => {
|
||||
const row = [
|
||||
msg.timestamp || '',
|
||||
msg.protocol || '',
|
||||
msg.address || '',
|
||||
msg.function || '',
|
||||
msg.msg_type || '',
|
||||
'"' + (msg.message || '').replace(/"/g, '""') + '"'
|
||||
];
|
||||
csv.push(row.join(','));
|
||||
});
|
||||
downloadFile(csv.join('\n'), 'intercept_messages.csv', 'text/csv');
|
||||
}
|
||||
|
||||
function exportJSON() {
|
||||
if (allMessages.length === 0) {
|
||||
alert('No messages to export');
|
||||
return;
|
||||
}
|
||||
downloadFile(JSON.stringify(allMessages, null, 2), 'intercept_messages.json', 'application/json');
|
||||
}
|
||||
|
||||
function downloadFile(content, filename, type) {
|
||||
const blob = new Blob([content], { type });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
// Auto-scroll setting
|
||||
let autoScroll = localStorage.getItem('autoScroll') !== 'false';
|
||||
|
||||
function toggleAutoScroll() {
|
||||
autoScroll = !autoScroll;
|
||||
localStorage.setItem('autoScroll', autoScroll);
|
||||
updateAutoScrollButton();
|
||||
}
|
||||
|
||||
function updateAutoScrollButton() {
|
||||
const btn = document.getElementById('autoScrollBtn');
|
||||
if (btn) {
|
||||
btn.innerHTML = autoScroll ? '⬇ AUTO-SCROLL ON' : '⬇ AUTO-SCROLL OFF';
|
||||
btn.classList.toggle('active', autoScroll);
|
||||
}
|
||||
}
|
||||
|
||||
// Signal activity meter
|
||||
let signalActivity = 0;
|
||||
let lastMessageTime = 0;
|
||||
|
||||
function updateSignalMeter() {
|
||||
const now = Date.now();
|
||||
const timeSinceLastMsg = now - lastMessageTime;
|
||||
|
||||
// Decay signal activity over time
|
||||
if (timeSinceLastMsg > 1000) {
|
||||
signalActivity = Math.max(0, signalActivity - 0.05);
|
||||
}
|
||||
|
||||
const meter = document.getElementById('signalMeter');
|
||||
const bars = meter?.querySelectorAll('.signal-bar');
|
||||
if (bars) {
|
||||
const activeBars = Math.ceil(signalActivity * bars.length);
|
||||
bars.forEach((bar, i) => {
|
||||
bar.classList.toggle('active', i < activeBars);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function pulseSignal() {
|
||||
signalActivity = Math.min(1, signalActivity + 0.4);
|
||||
lastMessageTime = Date.now();
|
||||
|
||||
// Flash waterfall canvas
|
||||
const canvas = document.getElementById('waterfallCanvas');
|
||||
if (canvas) {
|
||||
canvas.classList.add('active');
|
||||
setTimeout(() => canvas.classList.remove('active'), 500);
|
||||
}
|
||||
}
|
||||
|
||||
// Waterfall display
|
||||
const waterfallData = [];
|
||||
const maxWaterfallRows = 50;
|
||||
|
||||
function addWaterfallPoint(timestamp, intensity) {
|
||||
waterfallData.push({ time: timestamp, intensity });
|
||||
if (waterfallData.length > maxWaterfallRows * 100) {
|
||||
waterfallData.shift();
|
||||
}
|
||||
renderWaterfall();
|
||||
}
|
||||
|
||||
function renderWaterfall() {
|
||||
const canvas = document.getElementById('waterfallCanvas');
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
const width = canvas.width;
|
||||
const height = canvas.height;
|
||||
|
||||
// Shift existing image down
|
||||
const imageData = ctx.getImageData(0, 0, width, height - 2);
|
||||
ctx.putImageData(imageData, 0, 2);
|
||||
|
||||
// Draw new row at top
|
||||
ctx.fillStyle = '#000';
|
||||
ctx.fillRect(0, 0, width, 2);
|
||||
|
||||
// Add activity markers
|
||||
const now = Date.now();
|
||||
const recentData = waterfallData.filter(d => now - d.time < 100);
|
||||
recentData.forEach(d => {
|
||||
const x = Math.random() * width;
|
||||
const hue = 180 + (d.intensity * 60); // cyan to green
|
||||
ctx.fillStyle = `hsla(${hue}, 100%, 50%, ${d.intensity})`;
|
||||
ctx.fillRect(x - 2, 0, 4, 2);
|
||||
});
|
||||
}
|
||||
|
||||
// Relative timestamps
|
||||
function getRelativeTime(timestamp) {
|
||||
if (!timestamp) return '';
|
||||
const now = new Date();
|
||||
const parts = timestamp.split(':');
|
||||
const msgTime = new Date();
|
||||
msgTime.setHours(parseInt(parts[0]), parseInt(parts[1]), parseInt(parts[2]));
|
||||
|
||||
const diff = Math.floor((now - msgTime) / 1000);
|
||||
if (diff < 5) return 'just now';
|
||||
if (diff < 60) return diff + 's ago';
|
||||
if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
|
||||
return timestamp;
|
||||
}
|
||||
|
||||
function updateRelativeTimes() {
|
||||
document.querySelectorAll('.msg-time').forEach(el => {
|
||||
const ts = el.dataset.timestamp;
|
||||
if (ts) el.textContent = getRelativeTime(ts);
|
||||
});
|
||||
}
|
||||
|
||||
// Update timers
|
||||
setInterval(updateSignalMeter, 100);
|
||||
setInterval(updateRelativeTimes, 10000);
|
||||
|
||||
// Default presets (UK frequencies)
|
||||
const defaultPresets = ['153.350', '153.025'];
|
||||
|
||||
@@ -787,6 +1085,16 @@ HTML_TEMPLATE = '''
|
||||
// Initialize presets on load
|
||||
renderPresets();
|
||||
|
||||
// Initialize button states on load
|
||||
updateMuteButton();
|
||||
updateAutoScrollButton();
|
||||
|
||||
// Initialize audio context on first user interaction (required by browsers)
|
||||
document.addEventListener('click', function initAudioOnClick() {
|
||||
initAudio();
|
||||
document.removeEventListener('click', initAudioOnClick);
|
||||
}, { once: true });
|
||||
|
||||
function setFreq(freq) {
|
||||
document.getElementById('frequency').value = freq;
|
||||
}
|
||||
@@ -965,6 +1273,18 @@ HTML_TEMPLATE = '''
|
||||
placeholder.remove();
|
||||
}
|
||||
|
||||
// Store message for export
|
||||
allMessages.push(msg);
|
||||
|
||||
// Play audio alert
|
||||
playAlert();
|
||||
|
||||
// Update signal meter
|
||||
pulseSignal();
|
||||
|
||||
// Add to waterfall
|
||||
addWaterfallPoint(Date.now(), 0.8);
|
||||
|
||||
msgCount++;
|
||||
document.getElementById('msgCount').textContent = msgCount;
|
||||
|
||||
@@ -980,13 +1300,14 @@ HTML_TEMPLATE = '''
|
||||
}
|
||||
|
||||
const isNumeric = /^[0-9\s\-\*\#U]+$/.test(msg.message);
|
||||
const relativeTime = getRelativeTime(msg.timestamp);
|
||||
|
||||
const msgEl = document.createElement('div');
|
||||
msgEl.className = 'message ' + protoClass;
|
||||
msgEl.innerHTML = `
|
||||
<div class="header">
|
||||
<span class="protocol">${msg.protocol}</span>
|
||||
<span>${msg.timestamp}</span>
|
||||
<span class="msg-time" data-timestamp="${msg.timestamp}" title="${msg.timestamp}">${relativeTime}</span>
|
||||
</div>
|
||||
<div class="address">Address: ${msg.address}${msg.function ? ' | Func: ' + msg.function : ''}</div>
|
||||
<div class="content ${isNumeric ? 'numeric' : ''}">${escapeHtml(msg.message)}</div>
|
||||
@@ -994,6 +1315,11 @@ HTML_TEMPLATE = '''
|
||||
|
||||
output.insertBefore(msgEl, output.firstChild);
|
||||
|
||||
// Auto-scroll to top (newest messages)
|
||||
if (autoScroll) {
|
||||
output.scrollTop = 0;
|
||||
}
|
||||
|
||||
// Limit messages displayed
|
||||
while (output.children.length > 100) {
|
||||
output.removeChild(output.lastChild);
|
||||
@@ -1465,7 +1791,7 @@ def stream():
|
||||
|
||||
def main():
|
||||
print("=" * 50)
|
||||
print(" Pager Decoder")
|
||||
print(" INTERCEPT // Signal Intelligence")
|
||||
print(" POCSAG / FLEX using RTL-SDR + multimon-ng")
|
||||
print("=" * 50)
|
||||
print()
|
||||
|
||||
Reference in New Issue
Block a user