diff --git a/CHANGELOG.md b/CHANGELOG.md
index 92dbd83..6af9751 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,6 +1,36 @@
# Changelog
-All notable changes to INTERCEPT will be documented in this file.
+All notable changes to iNTERCEPT will be documented in this file.
+
+## [2.9.0] - 2026-01-10
+
+### Added
+- **Landing Page** - Animated welcome screen with logo reveal and "See the Invisible" tagline
+- **New Branding** - Redesigned logo featuring 'i' with signal wave brackets
+- **Logo Assets** - Full-size SVG logos in `/static/img/` for external use
+- **Instagram Promo** - Animated HTML promo video template in `/promo/` directory
+- **Listening Post Scanner** - Fully functional frequency scanning with signal detection
+ - Scan button toggles between start/stop states
+ - Signal hits logged with Listen button to tune directly
+ - Proper 4-column display (Time, Frequency, Modulation, Action)
+
+### Changed
+- **Rebranding** - Application renamed from "INTERCEPT" to "iNTERCEPT"
+- **Updated Tagline** - "Signal Intelligence & Counter Surveillance Platform"
+- **Setup Script** - Now installs Python packages via apt first (more reliable on Debian/Ubuntu)
+ - Uses `--system-site-packages` for venv to leverage apt packages
+ - Added fallback logic when pip fails
+- **Troubleshooting Docs** - Added sections for pip install issues and apt alternatives
+
+### Fixed
+- **Tuning Dial Audio** - Fixed audio stopping when using tuning knob
+ - Added restart prevention flags to avoid overlapping restarts
+ - Increased debounce time for smoother operation
+ - Added silent mode for programmatic value changes
+- **Scanner Signal Hits** - Fixed table column count and colspan
+- **Favicon** - Updated to new 'i' logo design
+
+---
## [2.0.0] - 2026-01-06
diff --git a/app.py b/app.py
index eb069bc..4a79270 100644
--- a/app.py
+++ b/app.py
@@ -421,6 +421,14 @@ def main() -> None:
from routes import register_blueprints
register_blueprints(app)
+ # Initialize WebSocket for audio streaming
+ try:
+ from routes.audio_websocket import init_audio_websocket
+ init_audio_websocket(app)
+ print("WebSocket audio streaming enabled")
+ except ImportError as e:
+ print(f"WebSocket audio disabled (install flask-sock): {e}")
+
print(f"Open http://localhost:{args.port} in your browser")
print()
print("Press Ctrl+C to stop")
diff --git a/config.py b/config.py
index fc37be9..5bd4ec8 100644
--- a/config.py
+++ b/config.py
@@ -7,7 +7,7 @@ import os
import sys
# Application version
-VERSION = "2.0.0"
+VERSION = "2.9.0"
def _get_env(key: str, default: str) -> str:
diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md
index e3b3da2..c87e236 100644
--- a/docs/TROUBLESHOOTING.md
+++ b/docs/TROUBLESHOOTING.md
@@ -14,6 +14,37 @@ pip install -r requirements.txt
python3 -m pip install -r requirements.txt
```
+### pip install fails for flask or skyfield
+
+On newer Debian/Ubuntu systems, pip may fail with permission errors or dependency conflicts. **Use apt instead:**
+
+```bash
+# Install Python packages via apt (recommended for Debian/Ubuntu)
+sudo apt install python3-flask python3-requests python3-serial python3-skyfield
+
+# Then create venv with system packages
+python3 -m venv --system-site-packages venv
+source venv/bin/activate
+sudo venv/bin/python intercept.py
+```
+
+### "error: externally-managed-environment" (pip blocked)
+
+This is PEP 668 protection on Ubuntu 23.04+, Debian 12+, and similar systems. Solutions:
+
+```bash
+# Option 1: Use apt packages (recommended)
+sudo apt install python3-flask python3-requests python3-serial python3-skyfield
+python3 -m venv --system-site-packages venv
+source venv/bin/activate
+
+# Option 2: Use pipx for isolated install
+pipx install flask
+
+# Option 3: Force pip (not recommended)
+pip install --break-system-packages flask
+```
+
### "TypeError: 'type' object is not subscriptable"
This error occurs on Python 3.7 or 3.8. **INTERCEPT requires Python 3.9 or later.**
@@ -33,18 +64,12 @@ pip install -r requirements.txt
sudo venv/bin/python intercept.py
```
-### "externally-managed-environment" error (Ubuntu 23.04+, Debian 12+)
+### Alternative: Use the setup script
-Modern systems use PEP 668 to protect system Python. Use a virtual environment:
+The setup script handles all installation automatically, including apt packages:
```bash
-# Option 1: Virtual environment (recommended)
-python3 -m venv venv
-source venv/bin/activate
-pip install -r requirements.txt
-sudo venv/bin/python intercept.py
-
-# Option 2: Use the setup script (auto-creates venv if needed)
+chmod +x setup.sh
./setup.sh
```
@@ -161,6 +186,144 @@ which rx_fm
If `rx_fm` is installed, select your device from the SDR dropdown in the Listening Post - HackRF, Airspy, LimeSDR, and SDRPlay are all supported.
+### Setting up Icecast for Listening Post Audio
+
+The Listening Post uses Icecast for low-latency audio streaming (2-10 second latency). Intercept will automatically start Icecast when you begin listening, but you must install and configure it first.
+
+**Install Icecast:**
+```bash
+# Ubuntu/Debian
+sudo apt install icecast2
+
+# macOS
+brew install icecast
+```
+
+**Configure Icecast:**
+
+During installation on Debian/Ubuntu, you'll be prompted to configure. Otherwise, edit `/etc/icecast2/icecast.xml`:
+
+```xml
+
+
+
+ hackme
+
+ your-admin-password
+
+ localhost
+
+ 8000
+
+
+```
+
+**Start Icecast:**
+```bash
+# Ubuntu/Debian (as service)
+sudo systemctl enable icecast2
+sudo systemctl start icecast2
+
+# Or run directly
+icecast -c /etc/icecast2/icecast.xml
+
+# macOS
+brew services start icecast
+# Or: icecast -c /usr/local/etc/icecast.xml
+```
+
+**Verify Icecast is running:**
+- Open http://localhost:8000 in your browser
+- You should see the Icecast status page
+
+**Configure Intercept (optional):**
+
+The default configuration expects Icecast on `127.0.0.1:8000` with source password `hackme` and mount point `/listen.mp3`. To change these, modify the scanner config in your API calls or update the defaults in `routes/listening_post.py`:
+
+```python
+scanner_config = {
+ # ... other settings ...
+ 'icecast_host': '127.0.0.1',
+ 'icecast_port': 8000,
+ 'icecast_mount': '/listen.mp3',
+ 'icecast_source_password': 'hackme',
+}
+```
+
+**Troubleshooting Icecast:**
+
+- **"Connection refused" errors**: Ensure Icecast is running on the configured port
+- **"Authentication failed"**: Check the source password matches between Icecast config and Intercept
+- **No audio playing**: Check Icecast status page (http://localhost:8000) to verify the mount point is active
+- **High latency**: Ensure nginx/reverse proxy isn't buffering - add `proxy_buffering off;` to nginx config
+
+### Audio Streaming Issues - Detailed Debugging
+
+If the Listening Post shows "Icecast mount not active" errors or audio doesn't play:
+
+**1. Check the console output for errors**
+
+Intercept now logs detailed error output. Look for lines starting with `[AUDIO]`:
+```
+[AUDIO] SDR errors: ... # Problems with rtl_fm/rx_fm (SDR not connected, device busy)
+[AUDIO] FFmpeg errors: ... # Problems with ffmpeg (wrong password, codec issues)
+```
+
+**2. Verify SDR is connected and working**
+```bash
+# For RTL-SDR
+rtl_test -t
+
+# You should see: "Found 1 device(s)"
+# If not, check USB connection and drivers
+```
+
+**3. Check Icecast password (macOS Homebrew)**
+
+On macOS with Homebrew, the Icecast config is at `/opt/homebrew/etc/icecast.xml`. Check the source password:
+```bash
+grep source-password /opt/homebrew/etc/icecast.xml
+```
+
+If it's different from `hackme`, update it in the Listening Post Icecast config panel, or change the Icecast config and restart:
+```bash
+brew services restart icecast
+```
+
+**4. Verify ffmpeg has required codecs**
+```bash
+# Check MP3 encoder is available
+ffmpeg -encoders 2>/dev/null | grep mp3
+
+# Should show: libmp3lame
+# If not, reinstall ffmpeg with all codecs:
+# macOS: brew reinstall ffmpeg
+# Linux: sudo apt install ffmpeg
+```
+
+**5. Test the pipeline manually**
+
+Try running the audio pipeline directly to see errors:
+```bash
+# Test rtl_fm (should produce raw audio data)
+rtl_fm -M am -f 118000000 -s 24000 -r 24000 -g 40 2>&1 | head -c 1000 | xxd | head
+
+# Test ffmpeg to Icecast (replace PASSWORD with your source password)
+rtl_fm -M am -f 118000000 -s 24000 -r 24000 -g 40 2>/dev/null | \
+ ffmpeg -f s16le -ar 24000 -ac 1 -i pipe:0 -c:a libmp3lame -b:a 64k \
+ -f mp3 -content_type audio/mpeg icecast://source:PASSWORD@127.0.0.1:8000/listen.mp3
+```
+
+**6. Common error messages and solutions**
+
+| Error | Cause | Solution |
+|-------|-------|----------|
+| `No supported devices found` | SDR not connected | Plug in SDR, check USB |
+| `Device or resource busy` | Another process using SDR | Click "Kill All Processes" |
+| `401 Unauthorized` | Wrong Icecast password | Check password in Icecast config |
+| `Connection refused` | Icecast not running | Start Icecast service |
+| `Encoder libmp3lame not found` | ffmpeg missing codec | Reinstall ffmpeg with codecs |
+
## WiFi Issues
### Monitor mode fails
diff --git a/favicon.svg b/favicon.svg
index 81b4829..3280665 100644
--- a/favicon.svg
+++ b/favicon.svg
@@ -1,8 +1,20 @@
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/promo/instagram-promo.html b/promo/instagram-promo.html
new file mode 100644
index 0000000..3cf5aa7
--- /dev/null
+++ b/promo/instagram-promo.html
@@ -0,0 +1,898 @@
+
+
+
+
+
+ iNTERCEPT Promo
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
iNTERCEPT
+
// See the Invisible
+
Signal Intelligence & Counter Surveillance
+
+
+
+
+
+
+
Capabilities
+
+
+
📡
+
SDR Scanning
+
Multi-band reception
+
+
+
🔐
+
Decryption
+
Signal analysis
+
+
+
🛰️
+
Tracking
+
Real-time monitoring
+
+
+
🔍
+
Detection
+
Counter surveillance
+
+
+
+
+
+
+
+
+
+
+
📟
+
+
PAGER
+
POCSAG & FLEX decoding
+
+
+
+
✈️
+
+
ADS-B
+
Aircraft tracking
+
+
+
+
📻
+
+
LISTENING POST
+
RF monitoring & scanning
+
+
+
+
📶
+
+
WiFi
+
Network reconnaissance
+
+
+
+
🔵
+
+
BLUETOOTH
+
Device & tracker detection
+
+
+
+
🌡️
+
+
SENSORS
+
433MHz IoT decoding
+
+
+
+
🛰️
+
+
SATELLITE
+
Pass prediction & tracking
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ [14:32:07]
+ POCSAG
+ Signal intercepted
+ 153.350 MHz
+
+
+ [14:32:09]
+ ADS-B
+ Aircraft detected: BA284
+ FL350
+
+
+ [14:32:11]
+ BT
+ AirTag detected nearby
+ -42 dBm
+
+
+ [14:32:14]
+ SENSOR
+ Temperature: 22.4C
+ 433.92 MHz
+
+
+ [14:32:16]
+ SCAN
+ Signal found
+ 145.500 MHz
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
iNTERCEPT
+
See the Invisible
+
Open Source
+
github.com/yourrepo/intercept
+
+
+
+
+
+
+
+
+
+
diff --git a/requirements.txt b/requirements.txt
index bb7cc85..68a3781 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -14,3 +14,4 @@ pyserial>=3.5
# ruff>=0.1.0
# black>=23.0.0
# mypy>=1.0.0
+flask-sock
diff --git a/routes/audio_websocket.py b/routes/audio_websocket.py
new file mode 100644
index 0000000..1971d88
--- /dev/null
+++ b/routes/audio_websocket.py
@@ -0,0 +1,256 @@
+"""WebSocket-based audio streaming for SDR."""
+
+import subprocess
+import threading
+import time
+import shutil
+import json
+from flask import Flask
+
+# Try to import flask-sock
+try:
+ from flask_sock import Sock
+ WEBSOCKET_AVAILABLE = True
+except ImportError:
+ WEBSOCKET_AVAILABLE = False
+ Sock = None
+
+from utils.logging import get_logger
+
+logger = get_logger('intercept.audio_ws')
+
+# Global state
+audio_process = None
+rtl_process = None
+process_lock = threading.Lock()
+current_config = {
+ 'frequency': 118.0,
+ 'modulation': 'am',
+ 'squelch': 0,
+ 'gain': 40,
+ 'device': 0
+}
+
+
+def find_rtl_fm():
+ return shutil.which('rtl_fm')
+
+
+def find_ffmpeg():
+ return shutil.which('ffmpeg')
+
+
+def kill_audio_processes():
+ """Kill any running audio processes."""
+ global audio_process, rtl_process
+
+ if audio_process:
+ try:
+ audio_process.terminate()
+ audio_process.wait(timeout=0.5)
+ except:
+ try:
+ audio_process.kill()
+ except:
+ pass
+ audio_process = None
+
+ if rtl_process:
+ try:
+ rtl_process.terminate()
+ rtl_process.wait(timeout=0.5)
+ except:
+ try:
+ rtl_process.kill()
+ except:
+ pass
+ rtl_process = None
+
+ # Kill any orphaned processes
+ try:
+ subprocess.run(['pkill', '-9', '-f', 'rtl_fm'], capture_output=True, timeout=1)
+ except:
+ pass
+
+ time.sleep(0.3)
+
+
+def start_audio_stream(config):
+ """Start rtl_fm + ffmpeg pipeline, return the ffmpeg process."""
+ global audio_process, rtl_process, current_config
+
+ kill_audio_processes()
+
+ rtl_fm = find_rtl_fm()
+ ffmpeg = find_ffmpeg()
+
+ if not rtl_fm or not ffmpeg:
+ logger.error("rtl_fm or ffmpeg not found")
+ return None
+
+ current_config.update(config)
+
+ freq = config.get('frequency', 118.0)
+ mod = config.get('modulation', 'am')
+ squelch = config.get('squelch', 0)
+ gain = config.get('gain', 40)
+ device = config.get('device', 0)
+
+ # Sample rates based on modulation
+ if mod == 'wfm':
+ sample_rate = 170000
+ resample_rate = 32000
+ elif mod in ['usb', 'lsb']:
+ sample_rate = 12000
+ resample_rate = 12000
+ else:
+ sample_rate = 24000
+ resample_rate = 24000
+
+ freq_hz = int(freq * 1e6)
+
+ rtl_cmd = [
+ rtl_fm,
+ '-M', mod,
+ '-f', str(freq_hz),
+ '-s', str(sample_rate),
+ '-r', str(resample_rate),
+ '-g', str(gain),
+ '-d', str(device),
+ '-l', str(squelch),
+ ]
+
+ # Encode to MP3 for browser compatibility
+ ffmpeg_cmd = [
+ ffmpeg,
+ '-hide_banner',
+ '-loglevel', 'error',
+ '-f', 's16le',
+ '-ar', str(resample_rate),
+ '-ac', '1',
+ '-i', 'pipe:0',
+ '-acodec', 'libmp3lame',
+ '-b:a', '128k',
+ '-f', 'mp3',
+ '-flush_packets', '1',
+ 'pipe:1'
+ ]
+
+ try:
+ logger.info(f"Starting rtl_fm: {freq} MHz, {mod}")
+ rtl_process = subprocess.Popen(
+ rtl_cmd,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.DEVNULL
+ )
+
+ audio_process = subprocess.Popen(
+ ffmpeg_cmd,
+ stdin=rtl_process.stdout,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.DEVNULL,
+ bufsize=0
+ )
+
+ rtl_process.stdout.close()
+
+ # Check processes started
+ time.sleep(0.2)
+ if rtl_process.poll() is not None or audio_process.poll() is not None:
+ logger.error("Audio process failed to start")
+ kill_audio_processes()
+ return None
+
+ return audio_process
+
+ except Exception as e:
+ logger.error(f"Failed to start audio: {e}")
+ kill_audio_processes()
+ return None
+
+
+def init_audio_websocket(app: Flask):
+ """Initialize WebSocket audio streaming."""
+ if not WEBSOCKET_AVAILABLE:
+ logger.warning("flask-sock not installed, WebSocket audio disabled")
+ return
+
+ sock = Sock(app)
+
+ @sock.route('/ws/audio')
+ def audio_stream(ws):
+ """WebSocket endpoint for audio streaming."""
+ logger.info("WebSocket audio client connected")
+
+ proc = None
+ streaming = False
+
+ try:
+ while True:
+ # Check for messages from client (non-blocking with timeout)
+ try:
+ msg = ws.receive(timeout=0.01)
+ if msg:
+ data = json.loads(msg)
+ cmd = data.get('cmd')
+
+ if cmd == 'start':
+ config = data.get('config', {})
+ logger.info(f"Starting audio: {config}")
+ with process_lock:
+ proc = start_audio_stream(config)
+ if proc:
+ streaming = True
+ ws.send(json.dumps({'status': 'started'}))
+ else:
+ ws.send(json.dumps({'status': 'error', 'message': 'Failed to start'}))
+
+ elif cmd == 'stop':
+ logger.info("Stopping audio")
+ streaming = False
+ with process_lock:
+ kill_audio_processes()
+ proc = None
+ ws.send(json.dumps({'status': 'stopped'}))
+
+ elif cmd == 'tune':
+ # Change frequency/modulation - restart stream
+ config = data.get('config', {})
+ logger.info(f"Retuning: {config}")
+ with process_lock:
+ proc = start_audio_stream(config)
+ if proc:
+ streaming = True
+ ws.send(json.dumps({'status': 'tuned'}))
+ else:
+ streaming = False
+ ws.send(json.dumps({'status': 'error', 'message': 'Failed to tune'}))
+
+ except TimeoutError:
+ pass
+ except Exception as e:
+ if "timed out" not in str(e).lower():
+ logger.error(f"WebSocket receive error: {e}")
+
+ # Stream audio data if active
+ if streaming and proc and proc.poll() is None:
+ try:
+ chunk = proc.stdout.read(4096)
+ if chunk:
+ ws.send(chunk)
+ except Exception as e:
+ logger.error(f"Audio read error: {e}")
+ streaming = False
+ elif streaming:
+ # Process died
+ streaming = False
+ ws.send(json.dumps({'status': 'error', 'message': 'Audio process died'}))
+ else:
+ time.sleep(0.01)
+
+ except Exception as e:
+ logger.info(f"WebSocket closed: {e}")
+ finally:
+ with process_lock:
+ kill_audio_processes()
+ logger.info("WebSocket audio client disconnected")
diff --git a/routes/listening_post.py b/routes/listening_post.py
index 6009004..4f71830 100644
--- a/routes/listening_post.py
+++ b/routes/listening_post.py
@@ -5,6 +5,8 @@ from __future__ import annotations
import json
import os
import queue
+import select
+import signal
import shutil
import subprocess
import threading
@@ -138,9 +140,6 @@ def scanner_loop():
last_signal_time = 0
signal_detected = False
- # Convert step from kHz to MHz
- step_mhz = scanner_config['step'] / 1000.0
-
try:
while scanner_running:
# Check if paused
@@ -148,6 +147,13 @@ def scanner_loop():
time.sleep(0.1)
continue
+ # Read config values on each iteration (allows live updates)
+ step_mhz = scanner_config['step'] / 1000.0
+ squelch = scanner_config['squelch']
+ mod = scanner_config['modulation']
+ gain = scanner_config['gain']
+ device = scanner_config['device']
+
scanner_current_freq = current_freq
# Notify clients of frequency change
@@ -162,7 +168,6 @@ def scanner_loop():
# Start rtl_fm at this frequency
freq_hz = int(current_freq * 1e6)
- mod = scanner_config['modulation']
# Sample rates
if mod == 'wfm':
@@ -182,8 +187,8 @@ def scanner_loop():
'-f', str(freq_hz),
'-s', str(sample_rate),
'-r', str(resample_rate),
- '-g', str(scanner_config['gain']),
- '-d', str(scanner_config['device']),
+ '-g', str(gain),
+ '-d', str(device),
]
# Add bias-t flag if enabled (for external LNA power)
if scanner_config.get('bias_t', False):
@@ -220,21 +225,22 @@ def scanner_loop():
# Analyze audio level
audio_detected = False
rms = 0
- threshold = 3000
+ threshold = 500
if len(audio_data) > 100:
import struct
samples = struct.unpack(f'{len(audio_data)//2}h', audio_data)
# Calculate RMS level (root mean square)
rms = (sum(s*s for s in samples) / len(samples)) ** 0.5
- # WFM (broadcast FM) has much higher audio output - needs higher threshold
- # AM/NFM have lower output levels
+ # Threshold based on squelch setting
+ # Lower squelch = more sensitive (lower threshold)
+ # squelch 0 = very sensitive, squelch 100 = only strong signals
if mod == 'wfm':
- # WFM: threshold 4000-12000 based on squelch
- threshold = 4000 + (scanner_config['squelch'] * 80)
+ # WFM: threshold 500-10000 based on squelch
+ threshold = 500 + (squelch * 95)
else:
- # AM/NFM: threshold 1500-8000 based on squelch
- threshold = 1500 + (scanner_config['squelch'] * 65)
+ # AM/NFM: threshold 300-6500 based on squelch
+ threshold = 300 + (squelch * 62)
audio_detected = rms > threshold
@@ -425,45 +431,33 @@ def _start_audio_stream(frequency: float, modulation: str):
'-ac', '1',
'-i', 'pipe:0',
'-acodec', 'libmp3lame',
+ '-b:a', '128k',
+ '-ar', '44100',
'-f', 'mp3',
- '-b:a', '96k',
- '-ar', '44100', # Resample to standard rate for browser compatibility
- '-flush_packets', '1',
- '-fflags', '+nobuffer',
- '-flags', '+low_delay',
'pipe:1'
]
try:
- logger.info(f"Starting SDR ({sdr_type.value}): {' '.join(sdr_cmd)}")
- audio_rtl_process = subprocess.Popen(
- sdr_cmd,
- stdout=subprocess.PIPE,
- stderr=subprocess.PIPE
- )
+ # Use shell pipe for reliable streaming (Python subprocess piping can be unreliable)
+ shell_cmd = f"{' '.join(sdr_cmd)} 2>/dev/null | {' '.join(encoder_cmd)}"
+ logger.info(f"Starting audio pipeline: {shell_cmd}")
- logger.info(f"Starting ffmpeg: {' '.join(encoder_cmd)}")
+ audio_rtl_process = None # Not used in shell mode
audio_process = subprocess.Popen(
- encoder_cmd,
- stdin=audio_rtl_process.stdout,
+ shell_cmd,
+ shell=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
- bufsize=0
+ bufsize=0,
+ start_new_session=True # Create new process group for clean shutdown
)
- audio_rtl_process.stdout.close()
-
- # Brief delay to check if processes started successfully
- time.sleep(0.2)
-
- if audio_rtl_process.poll() is not None:
- stderr = audio_rtl_process.stderr.read().decode() if audio_rtl_process.stderr else ''
- logger.error(f"SDR process exited immediately: {stderr}")
- return
+ # Brief delay to check if process started successfully
+ time.sleep(0.3)
if audio_process.poll() is not None:
stderr = audio_process.stderr.read().decode() if audio_process.stderr else ''
- logger.error(f"ffmpeg exited immediately: {stderr}")
+ logger.error(f"Audio pipeline exited immediately: {stderr}")
return
audio_running = True
@@ -485,31 +479,38 @@ def _stop_audio_stream_internal():
"""Internal stop (must hold lock)."""
global audio_process, audio_rtl_process, audio_running, audio_frequency
- if audio_process:
- try:
- audio_process.terminate()
- audio_process.wait(timeout=1)
- except:
- try:
- audio_process.kill()
- except:
- pass
- audio_process = None
-
- if audio_rtl_process:
- try:
- audio_rtl_process.terminate()
- audio_rtl_process.wait(timeout=1)
- except:
- try:
- audio_rtl_process.kill()
- except:
- pass
- audio_rtl_process = None
-
+ # Set flag first to stop any streaming
audio_running = False
audio_frequency = 0.0
+ # Kill the shell process and its children
+ if audio_process:
+ try:
+ # Kill entire process group (rtl_fm, ffmpeg, shell)
+ try:
+ os.killpg(os.getpgid(audio_process.pid), signal.SIGKILL)
+ except (ProcessLookupError, PermissionError):
+ audio_process.kill()
+ audio_process.wait(timeout=0.5)
+ except:
+ pass
+
+ audio_process = None
+ audio_rtl_process = None
+
+ # Kill any orphaned rtl_fm and ffmpeg processes
+ try:
+ subprocess.run(['pkill', '-9', 'rtl_fm'], capture_output=True, timeout=0.5)
+ except:
+ pass
+ try:
+ subprocess.run(['pkill', '-9', '-f', 'ffmpeg.*pipe:0'], capture_output=True, timeout=0.5)
+ except:
+ pass
+
+ # Pause for SDR device to be released (important for frequency/modulation changes)
+ time.sleep(0.7)
+
# ============================================
# API ENDPOINTS
@@ -658,6 +659,42 @@ def skip_signal() -> Response:
})
+@listening_post_bp.route('/scanner/config', methods=['POST'])
+def update_scanner_config() -> Response:
+ """Update scanner config while running (step, squelch, gain, dwell)."""
+ data = request.json or {}
+
+ updated = []
+
+ if 'step' in data:
+ scanner_config['step'] = float(data['step'])
+ updated.append(f"step={data['step']}kHz")
+
+ if 'squelch' in data:
+ scanner_config['squelch'] = int(data['squelch'])
+ updated.append(f"squelch={data['squelch']}")
+
+ if 'gain' in data:
+ scanner_config['gain'] = int(data['gain'])
+ updated.append(f"gain={data['gain']}")
+
+ if 'dwell_time' in data:
+ scanner_config['dwell_time'] = int(data['dwell_time'])
+ updated.append(f"dwell={data['dwell_time']}s")
+
+ if 'modulation' in data:
+ scanner_config['modulation'] = str(data['modulation']).lower()
+ updated.append(f"mod={data['modulation']}")
+
+ if updated:
+ logger.info(f"Scanner config updated: {', '.join(updated)}")
+
+ return jsonify({
+ 'status': 'updated',
+ 'config': scanner_config
+ })
+
+
@listening_post_bp.route('/scanner/status')
def scanner_status() -> Response:
"""Get scanner status."""
@@ -738,6 +775,8 @@ def start_audio() -> Response:
"""Start audio at specific frequency (manual mode)."""
global scanner_running
+ logger.info("Audio start request received")
+
# Stop scanner if running
if scanner_running:
scanner_running = False
@@ -787,17 +826,15 @@ def start_audio() -> Response:
_start_audio_stream(frequency, modulation)
if audio_running:
- add_activity_log('manual_tune', frequency, f'Manual tune to {frequency} MHz ({modulation.upper()})')
return jsonify({
'status': 'started',
'frequency': frequency,
- 'modulation': modulation,
- 'stream_url': '/listening/audio/stream'
+ 'modulation': modulation
})
else:
return jsonify({
'status': 'error',
- 'message': 'Failed to start audio. Check that rtl_fm and ffmpeg are installed, and that an SDR device is connected and not in use by another process.'
+ 'message': 'Failed to start audio. Check SDR device.'
}), 500
@@ -821,28 +858,30 @@ def audio_status() -> Response:
@listening_post_bp.route('/audio/stream')
def stream_audio() -> Response:
"""Stream MP3 audio."""
- # Wait briefly for audio to start (handles race condition with /audio/start)
- for _ in range(10):
+ # Wait for audio to be ready (up to 2 seconds for modulation/squelch changes)
+ for _ in range(40):
if audio_running and audio_process:
break
- time.sleep(0.1)
+ time.sleep(0.05)
if not audio_running or not audio_process:
- # Return empty audio response instead of JSON (browser audio element can't parse JSON)
return Response(b'', mimetype='audio/mpeg', status=204)
def generate():
- chunk_size = 8192 # Larger chunks for smoother streaming
try:
while audio_running and audio_process and audio_process.poll() is None:
- chunk = audio_process.stdout.read(chunk_size)
- if not chunk:
- # Small wait before checking again to avoid busy loop
- time.sleep(0.01)
- continue
- yield chunk
- except Exception as e:
- logger.error(f"Audio stream error: {e}")
+ # Use select to avoid blocking forever
+ ready, _, _ = select.select([audio_process.stdout], [], [], 2.0)
+ if ready:
+ chunk = audio_process.stdout.read(4096)
+ if chunk:
+ yield chunk
+ else:
+ break
+ except GeneratorExit:
+ pass
+ except:
+ pass
return Response(
generate(),
diff --git a/setup.sh b/setup.sh
index ca213aa..91c7f76 100644
--- a/setup.sh
+++ b/setup.sh
@@ -199,9 +199,21 @@ install_python_deps() {
return 0
fi
+ # On Debian/Ubuntu, try apt packages first as they're more reliable
+ if [[ "$OS" == "debian" ]]; then
+ info "Installing Python packages via apt (more reliable on Debian/Ubuntu)..."
+ $SUDO apt-get install -y python3-flask python3-requests python3-serial >/dev/null 2>&1 || true
+
+ # skyfield may not be available in all distros, try apt first then pip
+ if ! $SUDO apt-get install -y python3-skyfield >/dev/null 2>&1; then
+ warn "python3-skyfield not in apt, will try pip later"
+ fi
+ ok "Installed available Python packages via apt"
+ fi
+
if [[ ! -d venv ]]; then
- python3 -m venv venv
- ok "Created venv/"
+ python3 -m venv --system-site-packages venv
+ ok "Created venv/ (with system site-packages)"
else
ok "Using existing venv/"
fi
@@ -209,12 +221,23 @@ install_python_deps() {
# shellcheck disable=SC1091
source venv/bin/activate
- python -m pip install --upgrade pip setuptools wheel >/dev/null
+ python -m pip install --upgrade pip setuptools wheel >/dev/null 2>&1 || true
ok "Upgraded pip tooling"
progress "Installing Python dependencies"
- python -m pip install -r requirements.txt
- ok "Python dependencies installed"
+ # Try pip install, but don't fail if apt packages already satisfied deps
+ if ! python -m pip install -r requirements.txt 2>/dev/null; then
+ warn "Some pip packages failed - checking if apt packages cover them..."
+ # Verify critical packages are available
+ python -c "import flask; import requests" 2>/dev/null || {
+ fail "Critical Python packages (flask, requests) not installed"
+ echo "Try: sudo apt install python3-flask python3-requests"
+ exit 1
+ }
+ ok "Core Python dependencies available"
+ else
+ ok "Python dependencies installed"
+ fi
echo
}
@@ -373,7 +396,7 @@ install_debian_packages() {
export DEBIAN_FRONTEND=noninteractive
export NEEDRESTART_MODE=a
- TOTAL_STEPS=16
+ TOTAL_STEPS=15
CURRENT_STEP=0
progress "Updating APT package lists"
@@ -409,8 +432,11 @@ install_debian_packages() {
progress "Installing gpsd"
apt_install gpsd gpsd-clients || true
- progress "Installing Python venv"
- apt_install python3-venv || true
+ progress "Installing Python packages"
+ apt_install python3-venv python3-pip || true
+ # Install Python packages via apt (more reliable than pip on modern Debian/Ubuntu)
+ $SUDO apt-get install -y python3-flask python3-requests python3-serial >/dev/null 2>&1 || true
+ $SUDO apt-get install -y python3-skyfield >/dev/null 2>&1 || true
progress "Installing dump1090"
if ! cmd_exists dump1090; then
diff --git a/static/css/index.css b/static/css/index.css
index 52c8b72..48f20c3 100644
--- a/static/css/index.css
+++ b/static/css/index.css
@@ -82,6 +82,228 @@ body {
-webkit-font-smoothing: antialiased;
}
+/* ============================================
+ LANDING PAGE / SPLASH SCREEN
+ ============================================ */
+
+.landing-overlay {
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background: var(--bg-primary);
+ z-index: 10000;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ overflow: hidden;
+}
+
+.landing-overlay::before {
+ content: '';
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background:
+ radial-gradient(circle at 50% 50%, rgba(74, 158, 255, 0.03) 0%, transparent 50%),
+ linear-gradient(180deg, transparent 0%, rgba(0, 212, 255, 0.02) 100%);
+ pointer-events: none;
+}
+
+.landing-content {
+ text-align: center;
+ z-index: 1;
+ animation: landingFadeIn 1s ease-out;
+}
+
+@keyframes landingFadeIn {
+ from {
+ opacity: 0;
+ transform: translateY(20px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+.landing-logo {
+ margin-bottom: 30px;
+ animation: logoPulse 3s ease-in-out infinite;
+}
+
+@keyframes logoPulse {
+ 0%, 100% {
+ filter: drop-shadow(0 0 20px rgba(0, 212, 255, 0.3));
+ }
+ 50% {
+ filter: drop-shadow(0 0 40px rgba(0, 212, 255, 0.6));
+ }
+}
+
+.landing-logo .signal-wave {
+ animation: signalPulse 2s ease-in-out infinite;
+}
+
+.landing-logo .signal-wave-1 {
+ animation-delay: 0s;
+}
+
+.landing-logo .signal-wave-2 {
+ animation-delay: 0.2s;
+}
+
+.landing-logo .signal-wave-3 {
+ animation-delay: 0.4s;
+}
+
+@keyframes signalPulse {
+ 0%, 100% {
+ opacity: 0.3;
+ }
+ 50% {
+ opacity: 1;
+ }
+}
+
+.landing-logo .logo-dot {
+ animation: dotPulse 1.5s ease-in-out infinite;
+}
+
+@keyframes dotPulse {
+ 0%, 100% {
+ fill: #00ff88;
+ filter: drop-shadow(0 0 5px rgba(0, 255, 136, 0.5));
+ }
+ 50% {
+ fill: #00ffaa;
+ filter: drop-shadow(0 0 15px rgba(0, 255, 136, 0.9));
+ }
+}
+
+.landing-title {
+ font-family: 'JetBrains Mono', monospace;
+ font-size: 4rem;
+ font-weight: 700;
+ color: var(--text-primary);
+ letter-spacing: 0.3em;
+ margin: 0 0 10px 0;
+ text-shadow: 0 0 30px rgba(0, 212, 255, 0.3);
+}
+
+.landing-tagline {
+ font-family: 'JetBrains Mono', monospace;
+ font-size: 1.2rem;
+ color: #00d4ff;
+ letter-spacing: 0.2em;
+ margin: 0 0 8px 0;
+ opacity: 0.9;
+}
+
+.landing-subtitle {
+ font-family: 'Inter', sans-serif;
+ font-size: 0.9rem;
+ color: var(--text-secondary);
+ letter-spacing: 0.15em;
+ text-transform: uppercase;
+ margin: 0 0 40px 0;
+}
+
+.landing-enter-btn {
+ background: transparent;
+ border: 2px solid #00d4ff;
+ color: #00d4ff;
+ padding: 15px 50px;
+ font-family: 'JetBrains Mono', monospace;
+ font-size: 1rem;
+ letter-spacing: 0.2em;
+ cursor: pointer;
+ transition: all 0.3s ease;
+ display: inline-flex;
+ align-items: center;
+ gap: 15px;
+ position: relative;
+ overflow: hidden;
+}
+
+.landing-enter-btn::before {
+ content: '';
+ position: absolute;
+ top: 0;
+ left: -100%;
+ width: 100%;
+ height: 100%;
+ background: linear-gradient(90deg, transparent, rgba(0, 212, 255, 0.2), transparent);
+ transition: left 0.5s ease;
+}
+
+.landing-enter-btn:hover::before {
+ left: 100%;
+}
+
+.landing-enter-btn:hover {
+ background: rgba(0, 212, 255, 0.1);
+ box-shadow: 0 0 30px rgba(0, 212, 255, 0.3), inset 0 0 20px rgba(0, 212, 255, 0.1);
+ transform: scale(1.02);
+}
+
+.landing-enter-btn .btn-icon {
+ transition: transform 0.3s ease;
+}
+
+.landing-enter-btn:hover .btn-icon {
+ transform: translateX(5px);
+}
+
+.landing-version {
+ font-family: 'JetBrains Mono', monospace;
+ font-size: 0.7rem;
+ color: var(--text-dim);
+ margin-top: 30px;
+ letter-spacing: 0.1em;
+}
+
+.landing-scanline {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 3px;
+ background: linear-gradient(90deg, transparent, #00d4ff, transparent);
+ animation: scanlineMove 4s linear infinite;
+ opacity: 0.5;
+}
+
+@keyframes scanlineMove {
+ 0% {
+ top: 0;
+ }
+ 100% {
+ top: 100%;
+ }
+}
+
+.landing-overlay.fade-out {
+ animation: landingFadeOut 0.5s ease-in forwards;
+}
+
+@keyframes landingFadeOut {
+ from {
+ opacity: 1;
+ }
+ to {
+ opacity: 0;
+ visibility: hidden;
+ }
+}
+
+/* ============================================
+ END LANDING PAGE
+ ============================================ */
+
.container {
max-width: 100%;
margin: 0;
@@ -117,7 +339,6 @@ header h1 {
font-size: 1.1rem;
font-weight: 600;
letter-spacing: 0.15em;
- text-transform: uppercase;
margin: 0;
display: inline;
vertical-align: middle;
@@ -1003,12 +1224,13 @@ header h1 .tagline {
.output-content {
flex: 1;
- padding: 12px;
+ padding: 10px;
overflow-y: auto;
font-family: 'JetBrains Mono', monospace;
- font-size: 12px;
+ font-size: 11px;
background: var(--bg-primary);
- min-height: 200px; /* Ensure minimum space for device cards */
+ min-height: 100px;
+ max-height: 250px;
}
.output-content::-webkit-scrollbar {
@@ -1777,6 +1999,25 @@ header h1 .tagline {
display: block;
}
+/* Satellite Dashboard Embed */
+.satellite-dashboard-embed {
+ width: 100%;
+ height: calc(100vh - 200px);
+ min-height: 700px;
+ max-height: 900px;
+ background: var(--bg-primary);
+ border-radius: 8px;
+ overflow: hidden;
+ border: 1px solid var(--border-color);
+}
+
+.satellite-dashboard-embed iframe {
+ width: 100%;
+ height: 100%;
+ border: none;
+ background: var(--bg-primary);
+}
+
/* Satellite Pass Predictor - Cool UI */
.pass-predictor {
display: grid;
@@ -3558,3 +3799,922 @@ body::before {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.5; transform: scale(0.8); }
}
+
+/* ============================================
+ RADIO CONTROL INTERFACE - Listening Post
+ Modern tactical radio-inspired UI
+ ============================================ */
+
+/* Radio Panel Container */
+.radio-panel {
+ display: flex;
+ flex-direction: column;
+ gap: 20px;
+ padding: 20px;
+ background: var(--bg-secondary);
+ border: 1px solid var(--border-color);
+ border-radius: 12px;
+}
+
+/* Frequency Display Section */
+.radio-display-section {
+ display: flex;
+ justify-content: center;
+ padding: 15px 0;
+}
+
+.freq-display {
+ background: rgba(0, 0, 0, 0.8);
+ border: 1px solid var(--border-color);
+ border-radius: 12px;
+ padding: 25px 40px;
+ text-align: center;
+ box-shadow:
+ inset 0 0 40px rgba(0, 0, 0, 0.6),
+ 0 0 30px var(--accent-cyan-dim);
+ position: relative;
+ overflow: hidden;
+}
+
+.freq-display::before {
+ content: '';
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ height: 1px;
+ background: linear-gradient(90deg,
+ transparent 0%,
+ var(--accent-cyan) 50%,
+ transparent 100%
+ );
+ opacity: 0.5;
+}
+
+.freq-status {
+ font-family: 'Orbitron', monospace;
+ font-size: 11px;
+ font-weight: 600;
+ color: var(--text-muted);
+ text-transform: uppercase;
+ letter-spacing: 3px;
+ margin-bottom: 8px;
+}
+
+.freq-status.active {
+ color: var(--accent-cyan);
+ animation: freq-status-pulse 1.5s ease-in-out infinite;
+}
+
+.freq-status.signal {
+ color: var(--accent-green);
+}
+
+@keyframes freq-status-pulse {
+ 0%, 100% { opacity: 1; }
+ 50% { opacity: 0.6; }
+}
+
+.freq-digits {
+ font-family: 'JetBrains Mono', monospace;
+ font-size: 56px;
+ font-weight: 700;
+ color: var(--accent-cyan);
+ text-shadow:
+ 0 0 10px var(--accent-cyan),
+ 0 0 25px var(--accent-cyan),
+ 0 0 50px rgba(74, 158, 255, 0.4);
+ letter-spacing: 2px;
+ line-height: 1;
+}
+
+.freq-digits.signal {
+ color: var(--accent-green);
+ text-shadow:
+ 0 0 10px var(--accent-green),
+ 0 0 25px var(--accent-green),
+ 0 0 50px rgba(34, 197, 94, 0.4);
+}
+
+.freq-unit {
+ font-family: 'JetBrains Mono', monospace;
+ font-size: 20px;
+ color: var(--text-secondary);
+ margin-left: 8px;
+ vertical-align: baseline;
+}
+
+.freq-mode-badge {
+ display: inline-block;
+ padding: 6px 16px;
+ background: var(--accent-cyan-dim);
+ border: 1px solid var(--accent-cyan);
+ border-radius: 4px;
+ font-family: 'Orbitron', monospace;
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--accent-cyan);
+ margin-top: 12px;
+ letter-spacing: 1px;
+}
+
+/* Radio Controls Section */
+.radio-controls-section {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ gap: 30px;
+ padding: 20px;
+ background: var(--bg-primary);
+ border-radius: 8px;
+ border: 1px solid var(--border-color);
+}
+
+.radio-control-group {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 12px;
+}
+
+.radio-control-label {
+ font-family: 'Orbitron', monospace;
+ font-size: 10px;
+ font-weight: 600;
+ color: var(--text-secondary);
+ text-transform: uppercase;
+ letter-spacing: 2px;
+}
+
+/* Rotary Knob */
+.radio-knob {
+ width: 70px;
+ height: 70px;
+ background: linear-gradient(145deg, var(--bg-elevated), var(--bg-primary));
+ border-radius: 50%;
+ border: 2px solid var(--border-light);
+ position: relative;
+ cursor: grab;
+ box-shadow:
+ inset 0 2px 6px rgba(0, 0, 0, 0.4),
+ 0 4px 12px rgba(0, 0, 0, 0.3),
+ 0 0 20px var(--accent-cyan-dim);
+ transition: box-shadow 0.2s ease;
+}
+
+.radio-knob:hover {
+ box-shadow:
+ inset 0 2px 6px rgba(0, 0, 0, 0.4),
+ 0 4px 12px rgba(0, 0, 0, 0.3),
+ 0 0 30px var(--accent-cyan-dim);
+}
+
+.radio-knob:active {
+ cursor: grabbing;
+}
+
+.radio-knob::before {
+ content: '';
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ width: 20px;
+ height: 20px;
+ background: radial-gradient(circle, var(--bg-tertiary) 0%, var(--bg-primary) 100%);
+ border-radius: 50%;
+ transform: translate(-50%, -50%);
+ border: 1px solid var(--border-color);
+}
+
+.radio-knob::after {
+ content: '';
+ position: absolute;
+ width: 3px;
+ height: 22px;
+ background: var(--accent-cyan);
+ top: 6px;
+ left: 50%;
+ transform: translateX(-50%);
+ border-radius: 2px;
+ box-shadow: 0 0 8px var(--accent-cyan);
+}
+
+.radio-knob.active::after {
+ background: var(--accent-green);
+ box-shadow: 0 0 8px var(--accent-green);
+}
+
+/* Knob tick marks ring */
+.knob-ticks {
+ position: absolute;
+ width: 90px;
+ height: 90px;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ pointer-events: none;
+}
+
+.knob-tick {
+ position: absolute;
+ width: 2px;
+ height: 6px;
+ background: var(--border-light);
+ top: 0;
+ left: 50%;
+ transform-origin: center 45px;
+}
+
+.knob-tick.major {
+ height: 8px;
+ background: var(--text-muted);
+}
+
+.knob-label {
+ font-family: 'Orbitron', monospace;
+ font-size: 9px;
+ font-weight: 600;
+ color: var(--text-muted);
+ text-transform: uppercase;
+ letter-spacing: 1px;
+ margin-top: 4px;
+}
+
+.knob-value {
+ font-family: 'JetBrains Mono', monospace;
+ font-size: 16px;
+ font-weight: 600;
+ color: var(--accent-cyan);
+ text-shadow: 0 0 10px var(--accent-cyan-dim);
+}
+
+/* Knobs Row Container */
+.knobs-row {
+ display: flex;
+ gap: 30px;
+ justify-content: center;
+}
+
+.knob-container {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 6px;
+}
+
+/* Button Bank */
+.radio-button-bank {
+ display: flex;
+ gap: 2px;
+ background: var(--bg-primary);
+ padding: 4px;
+ border-radius: 6px;
+ border: 1px solid var(--border-color);
+}
+
+.radio-btn {
+ padding: 10px 14px;
+ background: var(--bg-secondary);
+ border: 1px solid transparent;
+ color: var(--text-secondary);
+ font-family: 'Orbitron', monospace;
+ font-size: 10px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 1px;
+ cursor: pointer;
+ transition: all 0.15s ease;
+ min-width: 50px;
+ text-align: center;
+}
+
+.radio-btn:first-child {
+ border-radius: 4px 0 0 4px;
+}
+
+.radio-btn:last-child {
+ border-radius: 0 4px 4px 0;
+}
+
+.radio-btn:only-child {
+ border-radius: 4px;
+}
+
+.radio-btn.active {
+ background: var(--accent-cyan);
+ color: var(--bg-primary);
+ box-shadow: 0 0 15px var(--accent-cyan-dim);
+}
+
+.radio-btn:hover:not(.active) {
+ background: var(--bg-elevated);
+ border-color: var(--accent-cyan);
+ color: var(--accent-cyan);
+}
+
+/* Tuning Dial */
+.tuning-dial {
+ width: 100px;
+ height: 100px;
+ background: conic-gradient(
+ from 0deg,
+ var(--bg-secondary) 0deg,
+ var(--bg-elevated) 45deg,
+ var(--bg-secondary) 90deg,
+ var(--bg-elevated) 135deg,
+ var(--bg-secondary) 180deg,
+ var(--bg-elevated) 225deg,
+ var(--bg-secondary) 270deg,
+ var(--bg-elevated) 315deg,
+ var(--bg-secondary) 360deg
+ );
+ border-radius: 50%;
+ border: 3px solid var(--border-light);
+ position: relative;
+ cursor: grab;
+ box-shadow:
+ 0 4px 20px rgba(0, 0, 0, 0.4),
+ inset 0 0 30px rgba(0, 0, 0, 0.4);
+}
+
+.tuning-dial:active {
+ cursor: grabbing;
+}
+
+.tuning-dial::after {
+ content: '';
+ position: absolute;
+ top: 4px;
+ left: 50%;
+ width: 4px;
+ height: 12px;
+ background: var(--accent-cyan);
+ transform: translateX(-50%);
+ border-radius: 2px;
+ box-shadow: 0 0 10px var(--accent-cyan);
+}
+
+/* Fine Tune Buttons */
+.fine-tune-btns {
+ display: flex;
+ gap: 4px;
+ margin-top: 8px;
+}
+
+.tune-btn {
+ padding: 6px 10px;
+ background: var(--bg-secondary);
+ border: 1px solid var(--border-color);
+ color: var(--text-secondary);
+ font-family: 'JetBrains Mono', monospace;
+ font-size: 10px;
+ font-weight: 600;
+ border-radius: 4px;
+ cursor: pointer;
+ transition: all 0.15s ease;
+}
+
+.tune-btn:hover {
+ background: var(--bg-elevated);
+ border-color: var(--accent-cyan);
+ color: var(--accent-cyan);
+}
+
+.tune-btn:active {
+ background: var(--accent-cyan);
+ color: var(--bg-primary);
+}
+
+/* Arc Signal Meter */
+.signal-arc-meter {
+ width: 180px;
+ height: 100px;
+ position: relative;
+}
+
+.signal-arc-meter svg {
+ width: 100%;
+ height: 100%;
+}
+
+.signal-arc-bg {
+ fill: none;
+ stroke: var(--bg-primary);
+ stroke-width: 12;
+ stroke-linecap: round;
+}
+
+.signal-arc-fill {
+ fill: none;
+ stroke: url(#signal-gradient);
+ stroke-width: 12;
+ stroke-linecap: round;
+ stroke-dasharray: 188.5;
+ stroke-dashoffset: 188.5;
+ transition: stroke-dashoffset 0.1s ease-out;
+}
+
+.signal-arc-needle {
+ fill: var(--accent-cyan);
+ filter: drop-shadow(0 0 4px var(--accent-cyan));
+ transform-origin: 90px 90px;
+ transition: transform 0.1s ease-out;
+}
+
+.signal-arc-center {
+ fill: var(--bg-secondary);
+ stroke: var(--border-color);
+ stroke-width: 1;
+}
+
+.signal-arc-label {
+ font-family: 'JetBrains Mono', monospace;
+ font-size: 8px;
+ fill: var(--text-muted);
+}
+
+.signal-arc-value {
+ font-family: 'JetBrains Mono', monospace;
+ font-size: 14px;
+ font-weight: 600;
+ fill: var(--accent-cyan);
+ text-anchor: middle;
+}
+
+/* Scanner Section */
+.radio-scanner-section {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ padding: 15px;
+ background: var(--bg-primary);
+ border-radius: 8px;
+ border: 1px solid var(--border-color);
+}
+
+.scanner-range {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ justify-content: center;
+}
+
+.scanner-range input {
+ width: 100px;
+ padding: 8px 12px;
+ background: var(--bg-secondary);
+ border: 1px solid var(--border-color);
+ border-radius: 4px;
+ color: var(--accent-cyan);
+ font-family: 'JetBrains Mono', monospace;
+ font-size: 14px;
+ font-weight: 600;
+ text-align: center;
+}
+
+.scanner-range input:focus {
+ outline: none;
+ border-color: var(--accent-cyan);
+ box-shadow: 0 0 0 2px var(--accent-cyan-dim);
+}
+
+.scanner-range span {
+ font-family: 'Orbitron', monospace;
+ font-size: 12px;
+ color: var(--text-muted);
+}
+
+.scanner-buttons {
+ display: flex;
+ gap: 8px;
+ justify-content: center;
+}
+
+.radio-action-btn {
+ padding: 12px 24px;
+ border: 1px solid var(--border-color);
+ background: var(--bg-secondary);
+ color: var(--text-secondary);
+ font-family: 'Orbitron', monospace;
+ font-size: 11px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 2px;
+ border-radius: 4px;
+ cursor: pointer;
+ transition: all 0.2s ease;
+}
+
+.radio-action-btn:hover {
+ border-color: var(--accent-cyan);
+ color: var(--accent-cyan);
+}
+
+.radio-action-btn.scan {
+ background: var(--accent-cyan);
+ border-color: var(--accent-cyan);
+ color: var(--bg-primary);
+}
+
+.radio-action-btn.scan:hover {
+ background: #5aa8ff;
+ box-shadow: 0 0 20px var(--accent-cyan-dim);
+}
+
+.radio-action-btn.scan.active {
+ background: var(--accent-red);
+ border-color: var(--accent-red);
+}
+
+.radio-action-btn.scan.active:hover {
+ box-shadow: 0 0 20px var(--accent-red-dim);
+}
+
+.radio-action-btn.pause {
+ background: var(--accent-orange);
+ border-color: var(--accent-orange);
+ color: #000;
+}
+
+.radio-action-btn.pause:disabled {
+ opacity: 0.4;
+ cursor: not-allowed;
+}
+
+/* Scan Progress Bar */
+.scan-progress {
+ height: 6px;
+ background: var(--bg-secondary);
+ border-radius: 3px;
+ overflow: hidden;
+ margin-top: 8px;
+}
+
+.scan-bar {
+ height: 100%;
+ width: 0%;
+ background: linear-gradient(90deg, var(--accent-cyan), var(--accent-green));
+ border-radius: 3px;
+ transition: width 0.2s ease-out;
+ box-shadow: 0 0 10px var(--accent-cyan);
+}
+
+/* Activity Log Section */
+.radio-log-section {
+ background: var(--bg-primary);
+ border-radius: 8px;
+ border: 1px solid var(--border-color);
+ overflow: hidden;
+}
+
+.log-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 10px 15px;
+ background: var(--bg-secondary);
+ border-bottom: 1px solid var(--border-color);
+}
+
+.log-header span {
+ font-family: 'Orbitron', monospace;
+ font-size: 10px;
+ font-weight: 600;
+ color: var(--text-secondary);
+ text-transform: uppercase;
+ letter-spacing: 2px;
+}
+
+.log-toggle {
+ background: none;
+ border: none;
+ color: var(--text-muted);
+ cursor: pointer;
+ font-size: 14px;
+ padding: 4px 8px;
+}
+
+.log-toggle:hover {
+ color: var(--accent-cyan);
+}
+
+.log-content {
+ max-height: 200px;
+ overflow-y: auto;
+ padding: 10px 15px;
+ font-family: 'JetBrains Mono', monospace;
+ font-size: 11px;
+}
+
+/* Radio Meters Section */
+.radio-meters-section {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ gap: 30px;
+ padding: 15px;
+}
+
+.spectrum-display {
+ flex: 1;
+ max-width: 400px;
+}
+
+.spectrum-display canvas {
+ width: 100%;
+ height: 80px;
+ background: rgba(0, 0, 0, 0.4);
+ border-radius: 6px;
+ border: 1px solid var(--border-color);
+}
+
+/* Responsive adjustments for radio panel */
+@media (max-width: 768px) {
+ .radio-controls-section {
+ flex-direction: column;
+ align-items: center;
+ gap: 20px;
+ }
+
+ .knobs-row {
+ flex-wrap: wrap;
+ }
+
+ .freq-digits {
+ font-size: 40px;
+ }
+
+ .radio-button-bank {
+ flex-wrap: wrap;
+ justify-content: center;
+ }
+}
+
+/* ============================================
+ RADIO MODULE BOX SYSTEM
+ Compact modular layout for Listening Post
+ ============================================ */
+
+.radio-module-box {
+ background: var(--bg-secondary);
+ border: 1px solid var(--border-color);
+ border-radius: 8px;
+ position: relative;
+}
+
+.radio-module-box::before {
+ content: '';
+ position: absolute;
+ top: 0;
+ left: 10px;
+ right: 10px;
+ height: 1px;
+ background: linear-gradient(90deg, transparent, var(--accent-cyan-dim), transparent);
+}
+
+/* Main Scanner Module - Primary Feature */
+.radio-module-box.scanner-main {
+ background: linear-gradient(180deg, var(--bg-secondary) 0%, rgba(0,20,30,0.95) 100%);
+ border: 1px solid var(--accent-cyan-dim);
+ box-shadow: 0 0 20px rgba(0, 212, 255, 0.1), inset 0 0 40px rgba(0, 0, 0, 0.3);
+}
+
+.radio-module-box.scanner-main::before {
+ height: 2px;
+ background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent);
+}
+
+.radio-module-box.scanner-main .module-header {
+ color: var(--accent-cyan);
+ font-size: 13px;
+ letter-spacing: 3px;
+}
+
+.module-header {
+ font-family: 'Orbitron', 'JetBrains Mono', monospace;
+ font-size: 10px;
+ font-weight: 600;
+ color: var(--accent-cyan);
+ text-transform: uppercase;
+ letter-spacing: 2px;
+ margin-bottom: 8px;
+}
+
+/* Compact Radio Button Bank */
+.radio-button-bank.compact {
+ gap: 2px;
+}
+
+.radio-button-bank.compact .radio-btn {
+ padding: 5px 10px;
+ font-size: 10px;
+}
+
+/* Radio Input Fields */
+.radio-input {
+ background: rgba(0, 0, 0, 0.4);
+ border: 1px solid var(--border-color);
+ border-radius: 4px;
+ color: var(--text-primary);
+ font-family: 'JetBrains Mono', monospace;
+ font-size: 12px;
+ padding: 6px 8px;
+ text-align: center;
+}
+
+.radio-input:focus {
+ outline: none;
+ border-color: var(--accent-cyan);
+ box-shadow: 0 0 5px var(--accent-cyan-dim);
+}
+
+/* Smaller Action Buttons for Compact Layout */
+.radio-action-btn {
+ padding: 6px 12px;
+ font-size: 10px;
+ font-family: 'Orbitron', monospace;
+ font-weight: 600;
+ text-transform: uppercase;
+ background: var(--bg-elevated);
+ border: 1px solid var(--accent-cyan);
+ color: var(--accent-cyan);
+ border-radius: 4px;
+ cursor: pointer;
+ transition: all 0.15s;
+ flex: 1;
+}
+
+.radio-action-btn:hover:not(:disabled) {
+ background: var(--accent-cyan);
+ color: var(--bg-primary);
+ box-shadow: 0 0 10px var(--accent-cyan-dim);
+}
+
+.radio-action-btn:disabled {
+ opacity: 0.4;
+ cursor: not-allowed;
+}
+
+.radio-action-btn.scan {
+ background: var(--accent-green);
+ border-color: var(--accent-green);
+ color: #000;
+}
+
+.radio-action-btn.scan:hover:not(:disabled) {
+ box-shadow: 0 0 15px rgba(0, 255, 136, 0.4);
+}
+
+/* Statistics Box */
+.stat-box {
+ background: rgba(0, 0, 0, 0.3);
+ border-radius: 6px;
+ padding: 10px;
+ text-align: center;
+}
+
+.stat-value {
+ font-family: 'JetBrains Mono', monospace;
+ font-size: 22px;
+ font-weight: bold;
+}
+
+.stat-label {
+ font-size: 9px;
+ color: var(--text-muted);
+ text-transform: uppercase;
+ letter-spacing: 1px;
+ margin-top: 2px;
+}
+
+/* Smaller Knobs for Compact Layout */
+.radio-module-box .radio-knob {
+ width: 55px;
+ height: 55px;
+}
+
+.radio-module-box .knob-container {
+ text-align: center;
+}
+
+.radio-module-box .knob-label {
+ font-size: 9px;
+ margin-top: 5px;
+}
+
+.radio-module-box .knob-value {
+ font-size: 11px;
+}
+
+/* Smaller Tuning Dial for Compact Layout */
+.radio-module-box .tuning-dial {
+ width: 90px;
+ height: 90px;
+}
+
+/* Fine Tune Buttons - Compact */
+.fine-tune-btns {
+ display: flex;
+ gap: 4px;
+ justify-content: center;
+}
+
+.tune-btn {
+ padding: 4px 8px;
+ font-size: 10px;
+ font-family: 'JetBrains Mono', monospace;
+ background: var(--bg-elevated);
+ border: 1px solid var(--border-color);
+ color: var(--text-secondary);
+ border-radius: 3px;
+ cursor: pointer;
+ transition: all 0.15s;
+}
+
+.tune-btn:hover {
+ border-color: var(--accent-cyan);
+ color: var(--accent-cyan);
+}
+
+/* Signal Hits Table Compact */
+.radio-module-box table th,
+.radio-module-box table td {
+ padding: 4px 6px;
+ border-bottom: 1px solid var(--border-color);
+}
+
+.radio-module-box table tbody tr:hover {
+ background: rgba(0, 212, 255, 0.05);
+}
+
+/* Log Content Compact */
+.radio-module-box .log-content {
+ background: rgba(0, 0, 0, 0.2);
+ border-radius: 4px;
+ padding: 8px;
+ font-family: 'JetBrains Mono', monospace;
+}
+
+/* Listening Mode Selector Buttons */
+.radio-mode-btn {
+ padding: 12px 24px;
+ font-family: 'Orbitron', 'JetBrains Mono', monospace;
+ font-size: 13px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 2px;
+ background: var(--bg-elevated);
+ border: 2px solid var(--border-color);
+ color: var(--text-secondary);
+ border-radius: 6px;
+ cursor: pointer;
+ transition: all 0.2s;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+
+.radio-mode-btn:hover {
+ border-color: var(--accent-cyan);
+ color: var(--accent-cyan);
+ background: rgba(0, 212, 255, 0.05);
+}
+
+.radio-mode-btn.active {
+ background: linear-gradient(135deg, rgba(0, 212, 255, 0.2), rgba(0, 255, 136, 0.1));
+ border-color: var(--accent-cyan);
+ color: var(--accent-cyan);
+ box-shadow: 0 0 20px rgba(0, 212, 255, 0.2), inset 0 0 20px rgba(0, 212, 255, 0.05);
+}
+
+/* Listening Mode Panels */
+.listening-mode-panel {
+ display: contents;
+}
+
+.listening-mode-panel[style*="display: none"] {
+ display: none !important;
+}
+
+/* Frequency Preset Buttons */
+.preset-freq-btn {
+ padding: 8px 14px;
+ font-family: 'JetBrains Mono', monospace;
+ font-size: 11px;
+ background: var(--bg-elevated);
+ border: 1px solid var(--border-color);
+ color: var(--text-secondary);
+ border-radius: 4px;
+ cursor: pointer;
+ transition: all 0.15s;
+}
+
+.preset-freq-btn:hover {
+ border-color: var(--accent-cyan);
+ color: var(--accent-cyan);
+ background: rgba(0, 212, 255, 0.1);
+}
+
+.preset-freq-btn:active {
+ transform: scale(0.98);
+}
diff --git a/static/css/satellite_dashboard.css b/static/css/satellite_dashboard.css
index fcaa3a6..12b4425 100644
--- a/static/css/satellite_dashboard.css
+++ b/static/css/satellite_dashboard.css
@@ -692,4 +692,36 @@ body {
.controls-bar {
grid-row: 4;
}
+}
+
+/* Embedded Mode Styles */
+body.embedded {
+ background: transparent;
+ min-height: auto;
+}
+
+body.embedded .header {
+ background: rgba(10, 12, 16, 0.95);
+ border-bottom: 1px solid var(--border-color);
+}
+
+body.embedded .header .logo {
+ font-size: 14px;
+}
+
+body.embedded .header .logo span {
+ font-size: 10px;
+}
+
+body.embedded .dashboard {
+ padding: 10px;
+ gap: 10px;
+}
+
+body.embedded .panel {
+ background: rgba(15, 18, 24, 0.95);
+}
+
+body.embedded .controls-bar {
+ padding: 10px 15px;
}
\ No newline at end of file
diff --git a/static/img/intercept-logo-dark.svg b/static/img/intercept-logo-dark.svg
new file mode 100644
index 0000000..19ab081
--- /dev/null
+++ b/static/img/intercept-logo-dark.svg
@@ -0,0 +1,51 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/img/intercept-logo.svg b/static/img/intercept-logo.svg
new file mode 100644
index 0000000..cf1b993
--- /dev/null
+++ b/static/img/intercept-logo.svg
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/js/components/radio-knob.js b/static/js/components/radio-knob.js
new file mode 100644
index 0000000..d6b6db8
--- /dev/null
+++ b/static/js/components/radio-knob.js
@@ -0,0 +1,226 @@
+/**
+ * Intercept - Radio Knob Component
+ * Interactive rotary knob control with drag-to-rotate
+ */
+
+class RadioKnob {
+ constructor(element, options = {}) {
+ this.element = element;
+ this.value = parseFloat(element.dataset.value) || 0;
+ this.min = parseFloat(element.dataset.min) || 0;
+ this.max = parseFloat(element.dataset.max) || 100;
+ this.step = parseFloat(element.dataset.step) || 1;
+ this.rotation = this.valueToRotation(this.value);
+ this.isDragging = false;
+ this.startY = 0;
+ this.startRotation = 0;
+ this.sensitivity = options.sensitivity || 1.5;
+ this.onChange = options.onChange || null;
+
+ this.bindEvents();
+ this.updateVisual();
+ }
+
+ valueToRotation(value) {
+ const range = this.max - this.min;
+ const normalized = (value - this.min) / range;
+ return normalized * 270 - 135; // -135 to +135 degrees
+ }
+
+ rotationToValue(rotation) {
+ const normalized = (rotation + 135) / 270;
+ let value = this.min + normalized * (this.max - this.min);
+
+ // Snap to step
+ value = Math.round(value / this.step) * this.step;
+ return Math.max(this.min, Math.min(this.max, value));
+ }
+
+ bindEvents() {
+ // Mouse events
+ this.element.addEventListener('mousedown', (e) => this.startDrag(e));
+ document.addEventListener('mousemove', (e) => this.drag(e));
+ document.addEventListener('mouseup', () => this.endDrag());
+
+ // Touch support
+ this.element.addEventListener('touchstart', (e) => {
+ e.preventDefault();
+ this.startDrag(e.touches[0]);
+ }, { passive: false });
+ document.addEventListener('touchmove', (e) => {
+ if (this.isDragging) {
+ e.preventDefault();
+ this.drag(e.touches[0]);
+ }
+ }, { passive: false });
+ document.addEventListener('touchend', () => this.endDrag());
+
+ // Scroll wheel support
+ this.element.addEventListener('wheel', (e) => this.handleWheel(e), { passive: false });
+
+ // Double-click to reset
+ this.element.addEventListener('dblclick', () => this.reset());
+ }
+
+ startDrag(e) {
+ this.isDragging = true;
+ this.startY = e.clientY;
+ this.startRotation = this.rotation;
+ this.element.style.cursor = 'grabbing';
+ this.element.classList.add('active');
+
+ // Play click sound if available
+ if (typeof playClickSound === 'function') {
+ playClickSound();
+ }
+ }
+
+ drag(e) {
+ if (!this.isDragging) return;
+
+ const deltaY = this.startY - e.clientY;
+ let newRotation = this.startRotation + deltaY * this.sensitivity;
+
+ // Clamp rotation
+ newRotation = Math.max(-135, Math.min(135, newRotation));
+
+ this.rotation = newRotation;
+ this.value = this.rotationToValue(this.rotation);
+ this.updateVisual();
+ this.dispatchChange();
+ }
+
+ endDrag() {
+ if (!this.isDragging) return;
+ this.isDragging = false;
+ this.element.style.cursor = 'grab';
+ this.element.classList.remove('active');
+ }
+
+ handleWheel(e) {
+ e.preventDefault();
+ const delta = e.deltaY > 0 ? -this.step : this.step;
+ const multiplier = e.shiftKey ? 5 : 1; // Faster with shift key
+ this.setValue(this.value + delta * multiplier);
+
+ // Play click sound if available
+ if (typeof playClickSound === 'function') {
+ playClickSound();
+ }
+ }
+
+ setValue(value, silent = false) {
+ this.value = Math.max(this.min, Math.min(this.max, value));
+ this.rotation = this.valueToRotation(this.value);
+ this.updateVisual();
+ if (!silent) {
+ this.dispatchChange();
+ }
+ }
+
+ getValue() {
+ return this.value;
+ }
+
+ reset() {
+ const defaultValue = parseFloat(this.element.dataset.default) ||
+ (this.min + this.max) / 2;
+ this.setValue(defaultValue);
+ }
+
+ updateVisual() {
+ this.element.style.transform = `rotate(${this.rotation}deg)`;
+
+ // Update associated value display
+ const valueDisplayId = this.element.id.replace('Knob', 'Value');
+ const valueDisplay = document.getElementById(valueDisplayId);
+ if (valueDisplay) {
+ valueDisplay.textContent = Math.round(this.value);
+ }
+
+ // Update data attribute
+ this.element.dataset.value = this.value;
+ }
+
+ dispatchChange() {
+ // Custom callback
+ if (this.onChange) {
+ this.onChange(this.value, this);
+ }
+
+ // Custom event
+ this.element.dispatchEvent(new CustomEvent('knobchange', {
+ detail: { value: this.value, knob: this },
+ bubbles: true
+ }));
+ }
+}
+
+/**
+ * Tuning Dial - Larger rotary control for frequency tuning
+ */
+class TuningDial extends RadioKnob {
+ constructor(element, options = {}) {
+ super(element, {
+ sensitivity: options.sensitivity || 0.8,
+ ...options
+ });
+
+ this.fineStep = options.fineStep || 0.025;
+ this.coarseStep = options.coarseStep || 0.2;
+ }
+
+ handleWheel(e) {
+ e.preventDefault();
+ const step = e.shiftKey ? this.fineStep : this.coarseStep;
+ const delta = e.deltaY > 0 ? -step : step;
+ this.setValue(this.value + delta);
+ }
+
+ // Override to not round to step for smooth tuning
+ rotationToValue(rotation) {
+ const normalized = (rotation + 135) / 270;
+ let value = this.min + normalized * (this.max - this.min);
+ return Math.max(this.min, Math.min(this.max, value));
+ }
+
+ updateVisual() {
+ this.element.style.transform = `rotate(${this.rotation}deg)`;
+
+ // Update associated value display with decimals
+ const valueDisplayId = this.element.id.replace('Dial', 'Value');
+ const valueDisplay = document.getElementById(valueDisplayId);
+ if (valueDisplay) {
+ valueDisplay.textContent = this.value.toFixed(3);
+ }
+
+ this.element.dataset.value = this.value;
+ }
+}
+
+/**
+ * Initialize all radio knobs on the page
+ */
+function initRadioKnobs() {
+ // Initialize standard knobs
+ document.querySelectorAll('.radio-knob').forEach(element => {
+ if (!element._knob) {
+ element._knob = new RadioKnob(element);
+ }
+ });
+
+ // Initialize tuning dials
+ document.querySelectorAll('.tuning-dial').forEach(element => {
+ if (!element._dial) {
+ element._dial = new TuningDial(element);
+ }
+ });
+}
+
+// Auto-initialize on DOM ready
+document.addEventListener('DOMContentLoaded', initRadioKnobs);
+
+// Export for use in modules
+if (typeof module !== 'undefined' && module.exports) {
+ module.exports = { RadioKnob, TuningDial, initRadioKnobs };
+}
diff --git a/static/js/core/app.js b/static/js/core/app.js
new file mode 100644
index 0000000..e8a8ee4
--- /dev/null
+++ b/static/js/core/app.js
@@ -0,0 +1,547 @@
+/**
+ * Intercept - Core Application Logic
+ * Global state, mode switching, and shared functionality
+ */
+
+// ============== GLOBAL STATE ==============
+
+// Mode state flags
+let eventSource = null;
+let isRunning = false;
+let isSensorRunning = false;
+let isAdsbRunning = false;
+let isWifiRunning = false;
+let isBtRunning = false;
+let currentMode = 'pager';
+
+// Message counters
+let msgCount = 0;
+let pocsagCount = 0;
+let flexCount = 0;
+let sensorCount = 0;
+let filteredCount = 0;
+
+// Device list (populated from server via Jinja2)
+let deviceList = [];
+
+// Auto-scroll setting
+let autoScroll = localStorage.getItem('autoScroll') !== 'false';
+
+// Mute setting
+let muted = localStorage.getItem('audioMuted') === 'true';
+
+// Observer location (load from localStorage or default to London)
+let observerLocation = (function() {
+ const saved = localStorage.getItem('observerLocation');
+ if (saved) {
+ try {
+ const parsed = JSON.parse(saved);
+ if (parsed.lat && parsed.lon) return parsed;
+ } catch (e) {}
+ }
+ return { lat: 51.5074, lon: -0.1278 };
+})();
+
+// Message storage for export
+let allMessages = [];
+
+// Track unique sensor devices
+let uniqueDevices = new Set();
+
+// SDR device usage tracking
+let sdrDeviceUsage = {};
+
+// ============== DISCLAIMER HANDLING ==============
+
+function checkDisclaimer() {
+ const accepted = localStorage.getItem('disclaimerAccepted');
+ if (accepted === 'true') {
+ document.getElementById('disclaimerModal').classList.add('disclaimer-hidden');
+ }
+}
+
+function acceptDisclaimer() {
+ localStorage.setItem('disclaimerAccepted', 'true');
+ document.getElementById('disclaimerModal').classList.add('disclaimer-hidden');
+}
+
+function declineDisclaimer() {
+ document.getElementById('disclaimerModal').classList.add('disclaimer-hidden');
+ document.getElementById('rejectionPage').classList.remove('disclaimer-hidden');
+}
+
+// ============== HEADER CLOCK ==============
+
+function updateHeaderClock() {
+ const now = new Date();
+ const utc = now.toISOString().substring(11, 19);
+ document.getElementById('headerUtcTime').textContent = utc;
+}
+
+// ============== HEADER STATS SYNC ==============
+
+function syncHeaderStats() {
+ // Pager stats
+ document.getElementById('headerMsgCount').textContent = msgCount;
+ document.getElementById('headerPocsagCount').textContent = pocsagCount;
+ document.getElementById('headerFlexCount').textContent = flexCount;
+
+ // Sensor stats
+ document.getElementById('headerSensorCount').textContent = document.getElementById('sensorCount')?.textContent || '0';
+ document.getElementById('headerDeviceTypeCount').textContent = document.getElementById('deviceCount')?.textContent || '0';
+
+ // WiFi stats
+ document.getElementById('headerApCount').textContent = document.getElementById('apCount')?.textContent || '0';
+ document.getElementById('headerClientCount').textContent = document.getElementById('clientCount')?.textContent || '0';
+ document.getElementById('headerHandshakeCount').textContent = document.getElementById('handshakeCount')?.textContent || '0';
+ document.getElementById('headerDroneCount').textContent = document.getElementById('droneCount')?.textContent || '0';
+
+ // Bluetooth stats
+ document.getElementById('headerBtDeviceCount').textContent = document.getElementById('btDeviceCount')?.textContent || '0';
+ document.getElementById('headerBtBeaconCount').textContent = document.getElementById('btBeaconCount')?.textContent || '0';
+
+ // Aircraft stats
+ document.getElementById('headerAircraftCount').textContent = document.getElementById('aircraftCount')?.textContent || '0';
+ document.getElementById('headerAdsbMsgCount').textContent = document.getElementById('adsbMsgCount')?.textContent || '0';
+ document.getElementById('headerIcaoCount').textContent = document.getElementById('icaoCount')?.textContent || '0';
+
+ // Satellite stats
+ document.getElementById('headerPassCount').textContent = document.getElementById('passCount')?.textContent || '0';
+}
+
+// ============== MODE SWITCHING ==============
+
+function switchMode(mode) {
+ // Stop any running scans when switching modes
+ if (isRunning && typeof stopDecoding === 'function') stopDecoding();
+ if (isSensorRunning && typeof stopSensorDecoding === 'function') stopSensorDecoding();
+ if (isWifiRunning && typeof stopWifiScan === 'function') stopWifiScan();
+ if (isBtRunning && typeof stopBtScan === 'function') stopBtScan();
+ if (isAdsbRunning && typeof stopAdsbScan === 'function') stopAdsbScan();
+
+ currentMode = mode;
+
+ // Remove active from all nav buttons, then add to the correct one
+ document.querySelectorAll('.mode-nav-btn').forEach(btn => btn.classList.remove('active'));
+ const modeMap = {
+ 'pager': 'pager', 'sensor': '433', 'aircraft': 'aircraft',
+ 'satellite': 'satellite', 'wifi': 'wifi', 'bluetooth': 'bluetooth',
+ 'listening': 'listening'
+ };
+ document.querySelectorAll('.mode-nav-btn').forEach(btn => {
+ const label = btn.querySelector('.nav-label');
+ if (label && label.textContent.toLowerCase().includes(modeMap[mode])) {
+ btn.classList.add('active');
+ }
+ });
+
+ // Toggle mode content visibility
+ document.getElementById('pagerMode').classList.toggle('active', mode === 'pager');
+ document.getElementById('sensorMode').classList.toggle('active', mode === 'sensor');
+ document.getElementById('aircraftMode').classList.toggle('active', mode === 'aircraft');
+ document.getElementById('satelliteMode').classList.toggle('active', mode === 'satellite');
+ document.getElementById('wifiMode').classList.toggle('active', mode === 'wifi');
+ document.getElementById('bluetoothMode').classList.toggle('active', mode === 'bluetooth');
+ document.getElementById('listeningPostMode').classList.toggle('active', mode === 'listening');
+
+ // Toggle stats visibility
+ document.getElementById('pagerStats').style.display = mode === 'pager' ? 'flex' : 'none';
+ document.getElementById('sensorStats').style.display = mode === 'sensor' ? 'flex' : 'none';
+ document.getElementById('aircraftStats').style.display = mode === 'aircraft' ? 'flex' : 'none';
+ document.getElementById('satelliteStats').style.display = mode === 'satellite' ? 'flex' : 'none';
+ document.getElementById('wifiStats').style.display = mode === 'wifi' ? 'flex' : 'none';
+ document.getElementById('btStats').style.display = mode === 'bluetooth' ? 'flex' : 'none';
+
+ // Hide signal meter - individual panels show signal strength where needed
+ document.getElementById('signalMeter').style.display = 'none';
+
+ // Update header stats groups
+ document.getElementById('headerPagerStats').classList.toggle('active', mode === 'pager');
+ document.getElementById('headerSensorStats').classList.toggle('active', mode === 'sensor');
+ document.getElementById('headerAircraftStats').classList.toggle('active', mode === 'aircraft');
+ document.getElementById('headerSatelliteStats').classList.toggle('active', mode === 'satellite');
+ document.getElementById('headerWifiStats').classList.toggle('active', mode === 'wifi');
+ document.getElementById('headerBtStats').classList.toggle('active', mode === 'bluetooth');
+
+ // Show/hide dashboard buttons in nav bar
+ document.getElementById('adsbDashboardBtn').style.display = mode === 'aircraft' ? 'inline-flex' : 'none';
+ document.getElementById('satelliteDashboardBtn').style.display = mode === 'satellite' ? 'inline-flex' : 'none';
+
+ // Update active mode indicator
+ const modeNames = {
+ 'pager': 'PAGER',
+ 'sensor': '433MHZ',
+ 'aircraft': 'AIRCRAFT',
+ 'satellite': 'SATELLITE',
+ 'wifi': 'WIFI',
+ 'bluetooth': 'BLUETOOTH',
+ 'listening': 'LISTENING POST'
+ };
+ document.getElementById('activeModeIndicator').innerHTML = ' ' + modeNames[mode];
+
+ // Toggle layout containers
+ document.getElementById('wifiLayoutContainer').style.display = mode === 'wifi' ? 'flex' : 'none';
+ document.getElementById('btLayoutContainer').style.display = mode === 'bluetooth' ? 'flex' : 'none';
+
+ // Respect the "Show Radar Display" checkbox for aircraft mode
+ const showRadar = document.getElementById('adsbEnableMap')?.checked;
+ document.getElementById('aircraftVisuals').style.display = (mode === 'aircraft' && showRadar) ? 'grid' : 'none';
+ document.getElementById('satelliteVisuals').style.display = mode === 'satellite' ? 'block' : 'none';
+ document.getElementById('listeningPostVisuals').style.display = mode === 'listening' ? 'grid' : 'none';
+
+ // Update output panel title based on mode
+ const titles = {
+ 'pager': 'Pager Decoder',
+ 'sensor': '433MHz Sensor Monitor',
+ 'aircraft': 'ADS-B Aircraft Tracker',
+ 'satellite': 'Satellite Monitor',
+ 'wifi': 'WiFi Scanner',
+ 'bluetooth': 'Bluetooth Scanner',
+ 'listening': 'Listening Post'
+ };
+ document.getElementById('outputTitle').textContent = titles[mode] || 'Signal Monitor';
+
+ // Show/hide Device Intelligence for modes that use it
+ const reconBtn = document.getElementById('reconBtn');
+ const intelBtn = document.querySelector('[onclick="exportDeviceDB()"]');
+ if (mode === 'satellite' || mode === 'aircraft' || mode === 'listening') {
+ document.getElementById('reconPanel').style.display = 'none';
+ if (reconBtn) reconBtn.style.display = 'none';
+ if (intelBtn) intelBtn.style.display = 'none';
+ } else {
+ if (reconBtn) reconBtn.style.display = 'inline-block';
+ if (intelBtn) intelBtn.style.display = 'inline-block';
+ if (typeof reconEnabled !== 'undefined' && reconEnabled) {
+ document.getElementById('reconPanel').style.display = 'block';
+ }
+ }
+
+ // Show RTL-SDR device section for modes that use it
+ document.getElementById('rtlDeviceSection').style.display =
+ (mode === 'pager' || mode === 'sensor' || mode === 'aircraft' || mode === 'listening') ? 'block' : 'none';
+
+ // Toggle mode-specific tool status displays
+ document.getElementById('toolStatusPager').style.display = (mode === 'pager') ? 'grid' : 'none';
+ document.getElementById('toolStatusSensor').style.display = (mode === 'sensor') ? 'grid' : 'none';
+ document.getElementById('toolStatusAircraft').style.display = (mode === 'aircraft') ? 'grid' : 'none';
+
+ // Hide waterfall and output console for modes with their own visualizations
+ document.querySelector('.waterfall-container').style.display =
+ (mode === 'satellite' || mode === 'listening' || mode === 'aircraft' || mode === 'wifi' || mode === 'bluetooth') ? 'none' : 'block';
+ document.getElementById('output').style.display =
+ (mode === 'satellite' || mode === 'aircraft' || mode === 'wifi' || mode === 'bluetooth') ? 'none' : 'block';
+ document.querySelector('.status-bar').style.display = (mode === 'satellite') ? 'none' : 'flex';
+
+ // Load interfaces and initialize visualizations when switching modes
+ if (mode === 'wifi') {
+ if (typeof refreshWifiInterfaces === 'function') refreshWifiInterfaces();
+ if (typeof initRadar === 'function') initRadar();
+ if (typeof initWatchList === 'function') initWatchList();
+ } else if (mode === 'bluetooth') {
+ if (typeof refreshBtInterfaces === 'function') refreshBtInterfaces();
+ if (typeof initBtRadar === 'function') initBtRadar();
+ } else if (mode === 'aircraft') {
+ if (typeof checkAdsbTools === 'function') checkAdsbTools();
+ if (typeof initAircraftRadar === 'function') initAircraftRadar();
+ } else if (mode === 'satellite') {
+ if (typeof initPolarPlot === 'function') initPolarPlot();
+ if (typeof initSatelliteList === 'function') initSatelliteList();
+ } else if (mode === 'listening') {
+ if (typeof checkScannerTools === 'function') checkScannerTools();
+ if (typeof checkAudioTools === 'function') checkAudioTools();
+ if (typeof populateScannerDeviceSelect === 'function') populateScannerDeviceSelect();
+ if (typeof populateAudioDeviceSelect === 'function') populateAudioDeviceSelect();
+ }
+}
+
+// ============== SECTION COLLAPSE ==============
+
+function toggleSection(el) {
+ el.closest('.section').classList.toggle('collapsed');
+}
+
+// ============== THEME MANAGEMENT ==============
+
+function toggleTheme() {
+ const html = document.documentElement;
+ const currentTheme = html.getAttribute('data-theme');
+ const newTheme = currentTheme === 'light' ? 'dark' : 'light';
+ html.setAttribute('data-theme', newTheme);
+ localStorage.setItem('theme', newTheme);
+
+ // Update button text
+ const btn = document.getElementById('themeToggle');
+ if (btn) {
+ btn.textContent = newTheme === 'light' ? '🌙' : '☀️';
+ }
+}
+
+function loadTheme() {
+ const savedTheme = localStorage.getItem('theme') || 'dark';
+ document.documentElement.setAttribute('data-theme', savedTheme);
+ const btn = document.getElementById('themeToggle');
+ if (btn) {
+ btn.textContent = savedTheme === 'light' ? '🌙' : '☀️';
+ }
+}
+
+// ============== AUTO-SCROLL ==============
+
+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);
+ }
+}
+
+// ============== SDR DEVICE MANAGEMENT ==============
+
+function getSelectedDevice() {
+ return document.getElementById('deviceSelect').value;
+}
+
+function getSelectedSDRType() {
+ return document.getElementById('sdrTypeSelect').value;
+}
+
+function reserveDevice(deviceIndex, modeId) {
+ sdrDeviceUsage[modeId] = deviceIndex;
+}
+
+function releaseDevice(modeId) {
+ delete sdrDeviceUsage[modeId];
+}
+
+function checkDeviceAvailability(requestingMode) {
+ const selectedDevice = parseInt(getSelectedDevice());
+ for (const [mode, device] of Object.entries(sdrDeviceUsage)) {
+ if (mode !== requestingMode && device === selectedDevice) {
+ alert(`Device ${selectedDevice} is currently in use by ${mode} mode. Please select a different device or stop the other scan first.`);
+ return false;
+ }
+ }
+ return true;
+}
+
+// ============== BIAS-T SETTINGS ==============
+
+function saveBiasTSetting() {
+ const enabled = document.getElementById('biasT')?.checked || false;
+ localStorage.setItem('biasTEnabled', enabled);
+}
+
+function getBiasTEnabled() {
+ return document.getElementById('biasT')?.checked || false;
+}
+
+function loadBiasTSetting() {
+ const saved = localStorage.getItem('biasTEnabled');
+ if (saved === 'true') {
+ const checkbox = document.getElementById('biasT');
+ if (checkbox) checkbox.checked = true;
+ }
+}
+
+// ============== REMOTE SDR ==============
+
+function toggleRemoteSDR() {
+ const useRemote = document.getElementById('useRemoteSDR').checked;
+ const configDiv = document.getElementById('remoteSDRConfig');
+ const localControls = document.querySelectorAll('#sdrTypeSelect, #deviceSelect');
+
+ if (useRemote) {
+ configDiv.style.display = 'block';
+ localControls.forEach(el => el.disabled = true);
+ } else {
+ configDiv.style.display = 'none';
+ localControls.forEach(el => el.disabled = false);
+ }
+}
+
+function getRemoteSDRConfig() {
+ const useRemote = document.getElementById('useRemoteSDR')?.checked;
+ if (!useRemote) return null;
+
+ const host = document.getElementById('rtlTcpHost')?.value || 'localhost';
+ const port = parseInt(document.getElementById('rtlTcpPort')?.value || '1234');
+
+ if (!host || isNaN(port)) {
+ alert('Please enter valid rtl_tcp host and port');
+ return false;
+ }
+
+ return { host, port };
+}
+
+// ============== OUTPUT DISPLAY ==============
+
+function showInfo(text) {
+ const output = document.getElementById('output');
+ if (!output) return;
+
+ const placeholder = output.querySelector('.placeholder');
+ if (placeholder) placeholder.remove();
+
+ const infoEl = document.createElement('div');
+ infoEl.className = 'info-msg';
+ infoEl.style.cssText = 'padding: 12px 15px; margin-bottom: 8px; background: #0a0a0a; border: 1px solid #1a1a1a; border-left: 2px solid #00d4ff; font-family: "JetBrains Mono", monospace; font-size: 11px; color: #888; word-break: break-all;';
+ infoEl.textContent = text;
+ output.insertBefore(infoEl, output.firstChild);
+}
+
+function showError(text) {
+ const output = document.getElementById('output');
+ if (!output) return;
+
+ const placeholder = output.querySelector('.placeholder');
+ if (placeholder) placeholder.remove();
+
+ const errorEl = document.createElement('div');
+ errorEl.className = 'error-msg';
+ errorEl.style.cssText = 'padding: 12px 15px; margin-bottom: 8px; background: #1a0a0a; border: 1px solid #2a1a1a; border-left: 2px solid #ff3366; font-family: "JetBrains Mono", monospace; font-size: 11px; color: #ff6688; word-break: break-all;';
+ errorEl.textContent = '⚠ ' + text;
+ output.insertBefore(errorEl, output.firstChild);
+}
+
+// ============== OBSERVER LOCATION ==============
+
+function saveObserverLocation() {
+ const lat = parseFloat(document.getElementById('adsbObsLat')?.value || document.getElementById('obsLat')?.value);
+ const lon = parseFloat(document.getElementById('adsbObsLon')?.value || document.getElementById('obsLon')?.value);
+
+ if (!isNaN(lat) && !isNaN(lon)) {
+ observerLocation = { lat, lon };
+ localStorage.setItem('observerLocation', JSON.stringify(observerLocation));
+
+ // Sync both input sets
+ const adsbLat = document.getElementById('adsbObsLat');
+ const adsbLon = document.getElementById('adsbObsLon');
+ const satLat = document.getElementById('obsLat');
+ const satLon = document.getElementById('obsLon');
+
+ if (adsbLat) adsbLat.value = lat.toFixed(4);
+ if (adsbLon) adsbLon.value = lon.toFixed(4);
+ if (satLat) satLat.value = lat.toFixed(4);
+ if (satLon) satLon.value = lon.toFixed(4);
+ }
+}
+
+function useGeolocation() {
+ if ('geolocation' in navigator) {
+ navigator.geolocation.getCurrentPosition(
+ (position) => {
+ const lat = position.coords.latitude;
+ const lon = position.coords.longitude;
+
+ observerLocation = { lat, lon };
+ localStorage.setItem('observerLocation', JSON.stringify(observerLocation));
+
+ // Update all input fields
+ const adsbLat = document.getElementById('adsbObsLat');
+ const adsbLon = document.getElementById('adsbObsLon');
+ const satLat = document.getElementById('obsLat');
+ const satLon = document.getElementById('obsLon');
+
+ if (adsbLat) adsbLat.value = lat.toFixed(4);
+ if (adsbLon) adsbLon.value = lon.toFixed(4);
+ if (satLat) satLat.value = lat.toFixed(4);
+ if (satLon) satLon.value = lon.toFixed(4);
+
+ showInfo(`Location set to ${lat.toFixed(4)}, ${lon.toFixed(4)}`);
+ },
+ (error) => {
+ showError('Geolocation failed: ' + error.message);
+ }
+ );
+ } else {
+ showError('Geolocation not supported by browser');
+ }
+}
+
+// ============== EXPORT FUNCTIONS ==============
+
+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');
+}
+
+// ============== INITIALIZATION ==============
+
+function initApp() {
+ // Check disclaimer
+ checkDisclaimer();
+
+ // Load theme
+ loadTheme();
+
+ // Start clock
+ updateHeaderClock();
+ setInterval(updateHeaderClock, 1000);
+
+ // Start stats sync
+ setInterval(syncHeaderStats, 500);
+
+ // Load bias-T setting
+ loadBiasTSetting();
+
+ // Initialize observer location inputs
+ const adsbLatInput = document.getElementById('adsbObsLat');
+ const adsbLonInput = document.getElementById('adsbObsLon');
+ const obsLatInput = document.getElementById('obsLat');
+ const obsLonInput = document.getElementById('obsLon');
+ if (adsbLatInput) adsbLatInput.value = observerLocation.lat.toFixed(4);
+ if (adsbLonInput) adsbLonInput.value = observerLocation.lon.toFixed(4);
+ if (obsLatInput) obsLatInput.value = observerLocation.lat.toFixed(4);
+ if (obsLonInput) obsLonInput.value = observerLocation.lon.toFixed(4);
+
+ // Update UI state
+ updateAutoScrollButton();
+
+ // Make sections collapsible
+ document.querySelectorAll('.section h3').forEach(h3 => {
+ h3.addEventListener('click', function() {
+ this.parentElement.classList.toggle('collapsed');
+ });
+ });
+
+ // Collapse all sections by default (except SDR Device which is first)
+ document.querySelectorAll('.section').forEach((section, index) => {
+ if (index > 0) {
+ section.classList.add('collapsed');
+ }
+ });
+}
+
+// Run initialization when DOM is ready
+document.addEventListener('DOMContentLoaded', initApp);
diff --git a/static/js/core/audio.js b/static/js/core/audio.js
new file mode 100644
index 0000000..2b58c0e
--- /dev/null
+++ b/static/js/core/audio.js
@@ -0,0 +1,281 @@
+/**
+ * Intercept - Audio System
+ * Web Audio API alerts, notifications, and sound effects
+ */
+
+// ============== AUDIO STATE ==============
+
+let audioContext = null;
+let audioMuted = localStorage.getItem('audioMuted') === 'true';
+let notificationsEnabled = false;
+
+// ============== AUDIO CONTEXT ==============
+
+/**
+ * Initialize the Web Audio API context
+ * Must be called after user interaction due to browser autoplay policies
+ */
+function initAudio() {
+ if (!audioContext) {
+ audioContext = new (window.AudioContext || window.webkitAudioContext)();
+ }
+ return audioContext;
+}
+
+/**
+ * Get or create the audio context
+ * @returns {AudioContext}
+ */
+function getAudioContext() {
+ if (!audioContext) {
+ audioContext = new (window.AudioContext || window.webkitAudioContext)();
+ }
+ return audioContext;
+}
+
+// ============== ALERT SOUNDS ==============
+
+/**
+ * Play a basic alert beep
+ * Used for message received notifications
+ */
+function playAlert() {
+ if (audioMuted || !audioContext) return;
+
+ try {
+ 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);
+ } catch (e) {
+ console.warn('Audio alert failed:', e);
+ }
+}
+
+/**
+ * Play alert sound by type
+ * @param {string} type - 'emergency', 'military', 'warning', 'info'
+ */
+function playAlertSound(type) {
+ if (audioMuted) return;
+
+ try {
+ const ctx = getAudioContext();
+ const oscillator = ctx.createOscillator();
+ const gainNode = ctx.createGain();
+
+ oscillator.connect(gainNode);
+ gainNode.connect(ctx.destination);
+
+ switch (type) {
+ case 'emergency':
+ // Urgent two-tone alert for emergencies
+ oscillator.frequency.setValueAtTime(880, ctx.currentTime);
+ oscillator.frequency.setValueAtTime(660, ctx.currentTime + 0.15);
+ oscillator.frequency.setValueAtTime(880, ctx.currentTime + 0.3);
+ gainNode.gain.setValueAtTime(0.3, ctx.currentTime);
+ gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.5);
+ oscillator.start(ctx.currentTime);
+ oscillator.stop(ctx.currentTime + 0.5);
+ break;
+
+ case 'military':
+ // Single tone for military aircraft detection
+ oscillator.frequency.setValueAtTime(523, ctx.currentTime);
+ gainNode.gain.setValueAtTime(0.2, ctx.currentTime);
+ gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.3);
+ oscillator.start(ctx.currentTime);
+ oscillator.stop(ctx.currentTime + 0.3);
+ break;
+
+ case 'warning':
+ // Warning tone (descending)
+ oscillator.frequency.setValueAtTime(660, ctx.currentTime);
+ oscillator.frequency.exponentialRampToValueAtTime(440, ctx.currentTime + 0.3);
+ gainNode.gain.setValueAtTime(0.25, ctx.currentTime);
+ gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.3);
+ oscillator.start(ctx.currentTime);
+ oscillator.stop(ctx.currentTime + 0.3);
+ break;
+
+ case 'info':
+ default:
+ // Simple info tone
+ oscillator.frequency.setValueAtTime(440, ctx.currentTime);
+ gainNode.gain.setValueAtTime(0.15, ctx.currentTime);
+ gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.15);
+ oscillator.start(ctx.currentTime);
+ oscillator.stop(ctx.currentTime + 0.15);
+ break;
+ }
+ } catch (e) {
+ console.warn('Audio alert failed:', e);
+ }
+}
+
+/**
+ * Play scanner signal detected sound
+ * A distinctive ascending tone for radio scanner
+ */
+function playSignalDetectedSound() {
+ if (audioMuted) return;
+
+ try {
+ const ctx = getAudioContext();
+ const oscillator = ctx.createOscillator();
+ const gainNode = ctx.createGain();
+
+ oscillator.connect(gainNode);
+ gainNode.connect(ctx.destination);
+
+ // Ascending tone
+ oscillator.frequency.setValueAtTime(400, ctx.currentTime);
+ oscillator.frequency.exponentialRampToValueAtTime(800, ctx.currentTime + 0.15);
+ oscillator.type = 'sine';
+
+ gainNode.gain.setValueAtTime(0.2, ctx.currentTime);
+ gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.2);
+
+ oscillator.start(ctx.currentTime);
+ oscillator.stop(ctx.currentTime + 0.2);
+ } catch (e) {
+ console.warn('Signal detected sound failed:', e);
+ }
+}
+
+/**
+ * Play a click sound for UI feedback
+ */
+function playClickSound() {
+ if (audioMuted) return;
+
+ try {
+ const ctx = getAudioContext();
+ const oscillator = ctx.createOscillator();
+ const gainNode = ctx.createGain();
+
+ oscillator.connect(gainNode);
+ gainNode.connect(ctx.destination);
+
+ oscillator.frequency.value = 1000;
+ oscillator.type = 'square';
+
+ gainNode.gain.setValueAtTime(0.1, ctx.currentTime);
+ gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.05);
+
+ oscillator.start(ctx.currentTime);
+ oscillator.stop(ctx.currentTime + 0.05);
+ } catch (e) {
+ console.warn('Click sound failed:', e);
+ }
+}
+
+// ============== MUTE CONTROL ==============
+
+/**
+ * Toggle mute state
+ */
+function toggleMute() {
+ audioMuted = !audioMuted;
+ localStorage.setItem('audioMuted', audioMuted);
+ updateMuteButton();
+}
+
+/**
+ * Set mute state
+ * @param {boolean} muted - Whether audio should be muted
+ */
+function setMuted(muted) {
+ audioMuted = muted;
+ localStorage.setItem('audioMuted', audioMuted);
+ updateMuteButton();
+}
+
+/**
+ * Get current mute state
+ * @returns {boolean}
+ */
+function isMuted() {
+ return audioMuted;
+}
+
+/**
+ * Update mute button UI
+ */
+function updateMuteButton() {
+ const btn = document.getElementById('muteBtn');
+ if (btn) {
+ btn.innerHTML = audioMuted ? '🔇 UNMUTE' : '🔊 MUTE';
+ btn.classList.toggle('muted', audioMuted);
+ }
+}
+
+// ============== DESKTOP NOTIFICATIONS ==============
+
+/**
+ * Request notification permission from user
+ */
+function requestNotificationPermission() {
+ if ('Notification' in window && Notification.permission === 'default') {
+ Notification.requestPermission().then(permission => {
+ notificationsEnabled = permission === 'granted';
+ if (notificationsEnabled && typeof showInfo === 'function') {
+ showInfo('🔔 Desktop notifications enabled');
+ }
+ });
+ }
+}
+
+/**
+ * Show a desktop notification
+ * @param {string} title - Notification title
+ * @param {string} body - Notification body
+ */
+function showNotification(title, body) {
+ if (notificationsEnabled && document.hidden) {
+ new Notification(title, {
+ body: body,
+ icon: '/favicon.ico',
+ tag: 'intercept-' + Date.now()
+ });
+ }
+}
+
+// ============== INITIALIZATION ==============
+
+/**
+ * Initialize audio system
+ * Should be called on first user interaction
+ */
+function initAudioSystem() {
+ // Initialize audio context
+ initAudio();
+
+ // Update mute button state
+ updateMuteButton();
+
+ // Check notification permission
+ if ('Notification' in window) {
+ if (Notification.permission === 'granted') {
+ notificationsEnabled = true;
+ } else if (Notification.permission === 'default') {
+ // Will request on first interaction
+ document.addEventListener('click', function requestOnce() {
+ requestNotificationPermission();
+ document.removeEventListener('click', requestOnce);
+ }, { once: true });
+ }
+ }
+}
+
+// Initialize on first user interaction (required for Web Audio API)
+document.addEventListener('click', function initOnInteraction() {
+ initAudio();
+ document.removeEventListener('click', initOnInteraction);
+}, { once: true });
diff --git a/static/js/core/utils.js b/static/js/core/utils.js
new file mode 100644
index 0000000..e741de3
--- /dev/null
+++ b/static/js/core/utils.js
@@ -0,0 +1,273 @@
+/**
+ * Intercept - Core Utility Functions
+ * Pure utility functions with no DOM dependencies
+ */
+
+// ============== HTML ESCAPING ==============
+
+/**
+ * Escape HTML to prevent XSS
+ * @param {string} text - Text to escape
+ * @returns {string} Escaped HTML
+ */
+function escapeHtml(text) {
+ const div = document.createElement('div');
+ div.textContent = text;
+ return div.innerHTML;
+}
+
+/**
+ * Escape text for use in HTML attributes (especially onclick handlers)
+ * @param {string} text - Text to escape
+ * @returns {string} Escaped attribute value
+ */
+function escapeAttr(text) {
+ if (text === null || text === undefined) return '';
+ var s = String(text);
+ s = s.replace(/&/g, '&');
+ s = s.replace(/'/g, ''');
+ s = s.replace(/"/g, '"');
+ s = s.replace(//g, '>');
+ return s;
+}
+
+// ============== VALIDATION ==============
+
+/**
+ * Validate MAC address format (XX:XX:XX:XX:XX:XX)
+ * @param {string} mac - MAC address to validate
+ * @returns {boolean} True if valid
+ */
+function isValidMac(mac) {
+ return /^([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$/.test(mac);
+}
+
+/**
+ * Validate WiFi channel (1-200 covers all bands)
+ * @param {string|number} ch - Channel number
+ * @returns {boolean} True if valid
+ */
+function isValidChannel(ch) {
+ const num = parseInt(ch, 10);
+ return !isNaN(num) && num >= 1 && num <= 200;
+}
+
+// ============== TIME FORMATTING ==============
+
+/**
+ * Get relative time string from timestamp
+ * @param {string} timestamp - Time string in HH:MM:SS format
+ * @returns {string} Relative time like "5s ago", "2m ago"
+ */
+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;
+}
+
+/**
+ * Format UTC time string
+ * @param {Date} date - Date object
+ * @returns {string} UTC time in HH:MM:SS format
+ */
+function formatUtcTime(date) {
+ return date.toISOString().substring(11, 19);
+}
+
+// ============== DISTANCE CALCULATIONS ==============
+
+/**
+ * Calculate distance between two points in nautical miles
+ * Uses Haversine formula
+ * @param {number} lat1 - Latitude of first point
+ * @param {number} lon1 - Longitude of first point
+ * @param {number} lat2 - Latitude of second point
+ * @param {number} lon2 - Longitude of second point
+ * @returns {number} Distance in nautical miles
+ */
+function calculateDistanceNm(lat1, lon1, lat2, lon2) {
+ const R = 3440.065; // Earth radius in nautical miles
+ const dLat = (lat2 - lat1) * Math.PI / 180;
+ const dLon = (lon2 - lon1) * Math.PI / 180;
+ const a = Math.sin(dLat/2) * Math.sin(dLat/2) +
+ Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
+ Math.sin(dLon/2) * Math.sin(dLon/2);
+ const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
+ return R * c;
+}
+
+/**
+ * Calculate distance between two points in kilometers
+ * @param {number} lat1 - Latitude of first point
+ * @param {number} lon1 - Longitude of first point
+ * @param {number} lat2 - Latitude of second point
+ * @param {number} lon2 - Longitude of second point
+ * @returns {number} Distance in kilometers
+ */
+function calculateDistanceKm(lat1, lon1, lat2, lon2) {
+ const R = 6371; // Earth radius in kilometers
+ const dLat = (lat2 - lat1) * Math.PI / 180;
+ const dLon = (lon2 - lon1) * Math.PI / 180;
+ const a = Math.sin(dLat/2) * Math.sin(dLat/2) +
+ Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
+ Math.sin(dLon/2) * Math.sin(dLon/2);
+ const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
+ return R * c;
+}
+
+// ============== FILE OPERATIONS ==============
+
+/**
+ * Download content as a file
+ * @param {string} content - File content
+ * @param {string} filename - Name for the downloaded file
+ * @param {string} type - MIME type
+ */
+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);
+}
+
+// ============== FREQUENCY FORMATTING ==============
+
+/**
+ * Format frequency value with proper units
+ * @param {number} freqMhz - Frequency in MHz
+ * @param {number} decimals - Number of decimal places (default 3)
+ * @returns {string} Formatted frequency string
+ */
+function formatFrequency(freqMhz, decimals = 3) {
+ return freqMhz.toFixed(decimals) + ' MHz';
+}
+
+/**
+ * Parse frequency string to MHz
+ * @param {string} freqStr - Frequency string (e.g., "118.0", "118.0 MHz")
+ * @returns {number} Frequency in MHz
+ */
+function parseFrequency(freqStr) {
+ return parseFloat(freqStr.replace(/[^\d.-]/g, ''));
+}
+
+// ============== LOCAL STORAGE HELPERS ==============
+
+/**
+ * Get item from localStorage with JSON parsing
+ * @param {string} key - Storage key
+ * @param {*} defaultValue - Default value if key doesn't exist
+ * @returns {*} Parsed value or default
+ */
+function getStorageItem(key, defaultValue = null) {
+ const saved = localStorage.getItem(key);
+ if (saved === null) return defaultValue;
+ try {
+ return JSON.parse(saved);
+ } catch (e) {
+ return saved;
+ }
+}
+
+/**
+ * Set item in localStorage with JSON stringification
+ * @param {string} key - Storage key
+ * @param {*} value - Value to store
+ */
+function setStorageItem(key, value) {
+ if (typeof value === 'object') {
+ localStorage.setItem(key, JSON.stringify(value));
+ } else {
+ localStorage.setItem(key, value);
+ }
+}
+
+// ============== ARRAY/OBJECT UTILITIES ==============
+
+/**
+ * Debounce function execution
+ * @param {Function} func - Function to debounce
+ * @param {number} wait - Wait time in milliseconds
+ * @returns {Function} Debounced function
+ */
+function debounce(func, wait) {
+ let timeout;
+ return function executedFunction(...args) {
+ const later = () => {
+ clearTimeout(timeout);
+ func(...args);
+ };
+ clearTimeout(timeout);
+ timeout = setTimeout(later, wait);
+ };
+}
+
+/**
+ * Throttle function execution
+ * @param {Function} func - Function to throttle
+ * @param {number} limit - Time limit in milliseconds
+ * @returns {Function} Throttled function
+ */
+function throttle(func, limit) {
+ let inThrottle;
+ return function executedFunction(...args) {
+ if (!inThrottle) {
+ func(...args);
+ inThrottle = true;
+ setTimeout(() => inThrottle = false, limit);
+ }
+ };
+}
+
+// ============== NUMBER FORMATTING ==============
+
+/**
+ * Format large numbers with K/M suffixes
+ * @param {number} num - Number to format
+ * @returns {string} Formatted string
+ */
+function formatNumber(num) {
+ if (num >= 1000000) {
+ return (num / 1000000).toFixed(1) + 'M';
+ }
+ if (num >= 1000) {
+ return (num / 1000).toFixed(1) + 'K';
+ }
+ return num.toString();
+}
+
+/**
+ * Clamp a number between min and max
+ * @param {number} num - Number to clamp
+ * @param {number} min - Minimum value
+ * @param {number} max - Maximum value
+ * @returns {number} Clamped value
+ */
+function clamp(num, min, max) {
+ return Math.min(Math.max(num, min), max);
+}
+
+/**
+ * Map a value from one range to another
+ * @param {number} value - Value to map
+ * @param {number} inMin - Input range minimum
+ * @param {number} inMax - Input range maximum
+ * @param {number} outMin - Output range minimum
+ * @param {number} outMax - Output range maximum
+ * @returns {number} Mapped value
+ */
+function mapRange(value, inMin, inMax, outMin, outMax) {
+ return (value - inMin) * (outMax - outMin) / (inMax - inMin) + outMin;
+}
diff --git a/static/js/modes/listening-post.js b/static/js/modes/listening-post.js
new file mode 100644
index 0000000..905b0b6
--- /dev/null
+++ b/static/js/modes/listening-post.js
@@ -0,0 +1,2194 @@
+/**
+ * Intercept - Listening Post Mode
+ * Frequency scanner and manual audio receiver
+ */
+
+// ============== STATE ==============
+
+let isScannerRunning = false;
+let isScannerPaused = false;
+let scannerEventSource = null;
+let scannerSignalCount = 0;
+let scannerLogEntries = [];
+let scannerFreqsScanned = 0;
+let scannerCycles = 0;
+let scannerStartFreq = 118;
+let scannerEndFreq = 137;
+let scannerSignalActive = false;
+
+// Audio state
+let isAudioPlaying = false;
+let audioToolsAvailable = { rtl_fm: false, ffmpeg: false };
+let audioReconnectAttempts = 0;
+const MAX_AUDIO_RECONNECT = 3;
+
+// WebSocket audio state
+let audioWebSocket = null;
+let audioQueue = [];
+let isWebSocketAudio = false;
+
+// Visualizer state
+let visualizerContext = null;
+let visualizerAnalyser = null;
+let visualizerSource = null;
+let visualizerAnimationId = null;
+let peakLevel = 0;
+let peakDecay = 0.95;
+
+// Track recent signal hits to prevent duplicates
+let recentSignalHits = new Map();
+
+// Direct listen state
+let isDirectListening = false;
+let currentModulation = 'am';
+
+// ============== PRESETS ==============
+
+const scannerPresets = {
+ fm: { start: 88, end: 108, step: 200, mod: 'wfm' },
+ air: { start: 118, end: 137, step: 25, mod: 'am' },
+ marine: { start: 156, end: 163, step: 25, mod: 'fm' },
+ amateur2m: { start: 144, end: 148, step: 12.5, mod: 'fm' },
+ pager: { start: 152, end: 160, step: 25, mod: 'fm' },
+ amateur70cm: { start: 420, end: 450, step: 25, mod: 'fm' }
+};
+
+const audioPresets = {
+ fm: { freq: 98.1, mod: 'wfm' },
+ airband: { freq: 121.5, mod: 'am' }, // Emergency/guard frequency
+ marine: { freq: 156.8, mod: 'fm' }, // Channel 16 - distress
+ amateur2m: { freq: 146.52, mod: 'fm' }, // 2m calling frequency
+ amateur70cm: { freq: 446.0, mod: 'fm' }
+};
+
+// ============== SCANNER TOOLS CHECK ==============
+
+function checkScannerTools() {
+ fetch('/listening/tools')
+ .then(r => r.json())
+ .then(data => {
+ const warnings = [];
+ if (!data.rtl_fm) {
+ warnings.push('rtl_fm not found - install rtl-sdr tools');
+ }
+ if (!data.ffmpeg) {
+ warnings.push('ffmpeg not found - install: brew install ffmpeg (macOS) or apt install ffmpeg (Linux)');
+ }
+
+ const warningDiv = document.getElementById('scannerToolsWarning');
+ const warningText = document.getElementById('scannerToolsWarningText');
+ if (warningDiv && warnings.length > 0) {
+ warningText.innerHTML = warnings.join(' ');
+ warningDiv.style.display = 'block';
+ document.getElementById('scannerStartBtn').disabled = true;
+ document.getElementById('scannerStartBtn').style.opacity = '0.5';
+ } else if (warningDiv) {
+ warningDiv.style.display = 'none';
+ document.getElementById('scannerStartBtn').disabled = false;
+ document.getElementById('scannerStartBtn').style.opacity = '1';
+ }
+ })
+ .catch(() => {});
+}
+
+// ============== SCANNER HELPERS ==============
+
+/**
+ * Get the currently selected device from the global SDR selector
+ */
+function getSelectedDevice() {
+ const select = document.getElementById('deviceSelect');
+ return parseInt(select?.value || '0');
+}
+
+/**
+ * Get the currently selected SDR type from the global selector
+ */
+function getSelectedSDRTypeForScanner() {
+ const select = document.getElementById('sdrTypeSelect');
+ return select?.value || 'rtlsdr';
+}
+
+// ============== SCANNER PRESETS ==============
+
+function applyScannerPreset() {
+ const preset = document.getElementById('scannerPreset').value;
+ if (preset !== 'custom' && scannerPresets[preset]) {
+ const p = scannerPresets[preset];
+ document.getElementById('scannerStartFreq').value = p.start;
+ document.getElementById('scannerEndFreq').value = p.end;
+ document.getElementById('scannerStep').value = p.step;
+ document.getElementById('scannerModulation').value = p.mod;
+ }
+}
+
+// ============== SCANNER CONTROLS ==============
+
+function toggleScanner() {
+ if (isScannerRunning) {
+ stopScanner();
+ } else {
+ startScanner();
+ }
+}
+
+function startScanner() {
+ // Use unified radio controls - read all current UI values
+ const startFreq = parseFloat(document.getElementById('radioScanStart')?.value || 118);
+ const endFreq = parseFloat(document.getElementById('radioScanEnd')?.value || 137);
+ const stepSelect = document.getElementById('radioScanStep');
+ const step = stepSelect ? parseFloat(stepSelect.value) : 25;
+ const modulation = currentModulation || 'am';
+ const squelch = parseInt(document.getElementById('radioSquelchValue')?.textContent) || 30;
+ const gain = parseInt(document.getElementById('radioGainValue')?.textContent) || 40;
+ const dwellSelect = document.getElementById('radioScanDwell');
+ const dwell = dwellSelect ? parseInt(dwellSelect.value) : 10;
+ const device = getSelectedDevice();
+
+ if (startFreq >= endFreq) {
+ if (typeof showNotification === 'function') {
+ showNotification('Scanner Error', 'End frequency must be greater than start');
+ }
+ return;
+ }
+
+ // Check if device is available
+ if (typeof checkDeviceAvailability === 'function' && !checkDeviceAvailability('scanner')) {
+ return;
+ }
+
+ // Store scanner range for progress calculation
+ scannerStartFreq = startFreq;
+ scannerEndFreq = endFreq;
+ scannerFreqsScanned = 0;
+ scannerCycles = 0;
+
+ // Update sidebar display
+ updateScannerDisplay('STARTING...', 'var(--accent-orange)');
+
+ // Show progress bars
+ const progressEl = document.getElementById('scannerProgress');
+ if (progressEl) {
+ progressEl.style.display = 'block';
+ document.getElementById('scannerRangeStart').textContent = startFreq.toFixed(1);
+ document.getElementById('scannerRangeEnd').textContent = endFreq.toFixed(1);
+ }
+
+ const mainProgress = document.getElementById('mainScannerProgress');
+ if (mainProgress) {
+ mainProgress.style.display = 'block';
+ document.getElementById('mainRangeStart').textContent = startFreq.toFixed(1) + ' MHz';
+ document.getElementById('mainRangeEnd').textContent = endFreq.toFixed(1) + ' MHz';
+ }
+
+ fetch('/listening/scanner/start', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ start_freq: startFreq,
+ end_freq: endFreq,
+ step: step,
+ modulation: modulation,
+ squelch: squelch,
+ gain: gain,
+ dwell_time: dwell,
+ device: device,
+ bias_t: typeof getBiasTEnabled === 'function' ? getBiasTEnabled() : false
+ })
+ })
+ .then(r => r.json())
+ .then(data => {
+ if (data.status === 'started') {
+ if (typeof reserveDevice === 'function') reserveDevice(device, 'scanner');
+ isScannerRunning = true;
+ isScannerPaused = false;
+ scannerSignalActive = false;
+
+ // Update controls (with null checks)
+ const startBtn = document.getElementById('scannerStartBtn');
+ if (startBtn) {
+ startBtn.textContent = 'Stop Scanner';
+ startBtn.classList.add('active');
+ }
+ const pauseBtn = document.getElementById('scannerPauseBtn');
+ if (pauseBtn) pauseBtn.disabled = false;
+
+ // Update radio scan button to show STOP
+ const radioScanBtn = document.getElementById('radioScanBtn');
+ if (radioScanBtn) {
+ radioScanBtn.innerHTML = '⏹ STOP';
+ radioScanBtn.style.background = 'var(--accent-red)';
+ radioScanBtn.style.borderColor = 'var(--accent-red)';
+ }
+
+ updateScannerDisplay('SCANNING', 'var(--accent-cyan)');
+ const statusText = document.getElementById('scannerStatusText');
+ if (statusText) statusText.textContent = 'Scanning...';
+
+ // Show level meter
+ const levelMeter = document.getElementById('scannerLevelMeter');
+ if (levelMeter) levelMeter.style.display = 'block';
+
+ connectScannerStream();
+ addScannerLogEntry('Scanner started', `Range: ${startFreq}-${endFreq} MHz, Step: ${step} kHz`);
+ if (typeof showNotification === 'function') {
+ showNotification('Scanner Started', `Scanning ${startFreq} - ${endFreq} MHz`);
+ }
+ } else {
+ updateScannerDisplay('ERROR', 'var(--accent-red)');
+ if (typeof showNotification === 'function') {
+ showNotification('Scanner Error', data.message || 'Failed to start');
+ }
+ }
+ })
+ .catch(err => {
+ const statusText = document.getElementById('scannerStatusText');
+ if (statusText) statusText.textContent = 'ERROR';
+ updateScannerDisplay('ERROR', 'var(--accent-red)');
+ if (typeof showNotification === 'function') {
+ showNotification('Scanner Error', err.message);
+ }
+ });
+}
+
+function stopScanner() {
+ fetch('/listening/scanner/stop', { method: 'POST' })
+ .then(() => {
+ if (typeof releaseDevice === 'function') releaseDevice('scanner');
+ isScannerRunning = false;
+ isScannerPaused = false;
+ scannerSignalActive = false;
+
+ // Update sidebar (with null checks)
+ const startBtn = document.getElementById('scannerStartBtn');
+ if (startBtn) {
+ startBtn.textContent = 'Start Scanner';
+ startBtn.classList.remove('active');
+ }
+ const pauseBtn = document.getElementById('scannerPauseBtn');
+ if (pauseBtn) {
+ pauseBtn.disabled = true;
+ pauseBtn.textContent = '⏸ Pause';
+ }
+
+ // Update radio scan button
+ const radioScanBtn = document.getElementById('radioScanBtn');
+ if (radioScanBtn) {
+ radioScanBtn.innerHTML = '📡 SCAN';
+ radioScanBtn.style.background = '';
+ radioScanBtn.style.borderColor = '';
+ }
+
+ updateScannerDisplay('STOPPED', 'var(--text-muted)');
+ const currentFreq = document.getElementById('scannerCurrentFreq');
+ if (currentFreq) currentFreq.textContent = '---.--- MHz';
+ const modLabel = document.getElementById('scannerModLabel');
+ if (modLabel) modLabel.textContent = '--';
+
+ const progressEl = document.getElementById('scannerProgress');
+ if (progressEl) progressEl.style.display = 'none';
+
+ const signalPanel = document.getElementById('scannerSignalPanel');
+ if (signalPanel) signalPanel.style.display = 'none';
+
+ const levelMeter = document.getElementById('scannerLevelMeter');
+ if (levelMeter) levelMeter.style.display = 'none';
+
+ const statusText = document.getElementById('scannerStatusText');
+ if (statusText) statusText.textContent = 'Ready';
+
+ // Update main display
+ const mainModeLabel = document.getElementById('mainScannerModeLabel');
+ if (mainModeLabel) {
+ mainModeLabel.textContent = 'SCANNER STOPPED';
+ document.getElementById('mainScannerFreq').textContent = '---.---';
+ document.getElementById('mainScannerFreq').style.color = 'var(--text-muted)';
+ document.getElementById('mainScannerMod').textContent = '--';
+ }
+
+ const mainAnim = document.getElementById('mainScannerAnimation');
+ if (mainAnim) mainAnim.style.display = 'none';
+
+ const mainProgress = document.getElementById('mainScannerProgress');
+ if (mainProgress) mainProgress.style.display = 'none';
+
+ const mainSignalAlert = document.getElementById('mainSignalAlert');
+ if (mainSignalAlert) mainSignalAlert.style.display = 'none';
+
+ // Stop scanner audio
+ const scannerAudio = document.getElementById('scannerAudioPlayer');
+ if (scannerAudio) {
+ scannerAudio.pause();
+ scannerAudio.src = '';
+ }
+
+ if (scannerEventSource) {
+ scannerEventSource.close();
+ scannerEventSource = null;
+ }
+ addScannerLogEntry('Scanner stopped', '');
+ })
+ .catch(() => {});
+}
+
+function pauseScanner() {
+ const endpoint = isScannerPaused ? '/listening/scanner/resume' : '/listening/scanner/pause';
+ fetch(endpoint, { method: 'POST' })
+ .then(r => r.json())
+ .then(data => {
+ isScannerPaused = !isScannerPaused;
+ const pauseBtn = document.getElementById('scannerPauseBtn');
+ if (pauseBtn) pauseBtn.textContent = isScannerPaused ? '▶ Resume' : '⏸ Pause';
+ const statusText = document.getElementById('scannerStatusText');
+ if (statusText) {
+ statusText.textContent = isScannerPaused ? 'PAUSED' : 'SCANNING';
+ statusText.style.color = isScannerPaused ? 'var(--accent-orange)' : 'var(--accent-green)';
+ }
+
+ const activityStatus = document.getElementById('scannerActivityStatus');
+ if (activityStatus) {
+ activityStatus.textContent = isScannerPaused ? 'PAUSED' : 'SCANNING';
+ activityStatus.style.color = isScannerPaused ? 'var(--accent-orange)' : 'var(--accent-green)';
+ }
+
+ // Update main display
+ const mainModeLabel = document.getElementById('mainScannerModeLabel');
+ if (mainModeLabel) {
+ mainModeLabel.textContent = isScannerPaused ? 'PAUSED' : 'SCANNING';
+ }
+
+ addScannerLogEntry(isScannerPaused ? 'Scanner paused' : 'Scanner resumed', '');
+ })
+ .catch(() => {});
+}
+
+function skipSignal() {
+ if (!isScannerRunning) {
+ if (typeof showNotification === 'function') {
+ showNotification('Scanner', 'Scanner is not running');
+ }
+ return;
+ }
+
+ fetch('/listening/scanner/skip', { method: 'POST' })
+ .then(r => r.json())
+ .then(data => {
+ if (data.status === 'skipped' && typeof showNotification === 'function') {
+ showNotification('Signal Skipped', `Continuing scan from ${data.frequency.toFixed(3)} MHz`);
+ }
+ })
+ .catch(err => {
+ if (typeof showNotification === 'function') {
+ showNotification('Skip Error', err.message);
+ }
+ });
+}
+
+// ============== SCANNER STREAM ==============
+
+function connectScannerStream() {
+ if (scannerEventSource) {
+ scannerEventSource.close();
+ }
+
+ scannerEventSource = new EventSource('/listening/scanner/stream');
+
+ scannerEventSource.onmessage = function(e) {
+ try {
+ const data = JSON.parse(e.data);
+ handleScannerEvent(data);
+ } catch (err) {
+ console.warn('Scanner parse error:', err);
+ }
+ };
+
+ scannerEventSource.onerror = function() {
+ if (isScannerRunning) {
+ setTimeout(connectScannerStream, 2000);
+ }
+ };
+}
+
+function handleScannerEvent(data) {
+ switch (data.type) {
+ case 'freq_change':
+ case 'scan_update':
+ handleFrequencyUpdate(data);
+ break;
+ case 'signal_found':
+ handleSignalFound(data);
+ break;
+ case 'signal_lost':
+ case 'signal_skipped':
+ handleSignalLost(data);
+ break;
+ case 'log':
+ if (data.entry && data.entry.type === 'scan_cycle') {
+ scannerCycles++;
+ const cyclesEl = document.getElementById('mainScanCycles');
+ if (cyclesEl) cyclesEl.textContent = scannerCycles;
+ }
+ break;
+ case 'stopped':
+ stopScanner();
+ break;
+ }
+}
+
+function handleFrequencyUpdate(data) {
+ const freqStr = data.frequency.toFixed(3);
+
+ const currentFreq = document.getElementById('scannerCurrentFreq');
+ if (currentFreq) currentFreq.textContent = freqStr + ' MHz';
+
+ const mainFreq = document.getElementById('mainScannerFreq');
+ if (mainFreq) mainFreq.textContent = freqStr;
+
+ // Update progress bar
+ const progress = ((data.frequency - scannerStartFreq) / (scannerEndFreq - scannerStartFreq)) * 100;
+ const progressBar = document.getElementById('scannerProgressBar');
+ if (progressBar) progressBar.style.width = Math.max(0, Math.min(100, progress)) + '%';
+
+ const mainProgressBar = document.getElementById('mainProgressBar');
+ if (mainProgressBar) mainProgressBar.style.width = Math.max(0, Math.min(100, progress)) + '%';
+
+ scannerFreqsScanned++;
+ const freqsEl = document.getElementById('mainFreqsScanned');
+ if (freqsEl) freqsEl.textContent = scannerFreqsScanned;
+
+ // Update level meter if present
+ if (data.level !== undefined) {
+ const levelPercent = Math.min(100, (data.level / 5000) * 100);
+ const levelBar = document.getElementById('scannerLevelBar');
+ if (levelBar) {
+ levelBar.style.width = levelPercent + '%';
+ if (data.detected) {
+ levelBar.style.background = 'var(--accent-green)';
+ } else if (data.level > (data.threshold || 0) * 0.7) {
+ levelBar.style.background = 'var(--accent-orange)';
+ } else {
+ levelBar.style.background = 'var(--accent-cyan)';
+ }
+ }
+ const levelValue = document.getElementById('scannerLevelValue');
+ if (levelValue) levelValue.textContent = data.level;
+ }
+
+ const statusText = document.getElementById('scannerStatusText');
+ if (statusText) statusText.textContent = `${freqStr} MHz${data.level !== undefined ? ` (level: ${data.level})` : ''}`;
+}
+
+function handleSignalFound(data) {
+ scannerSignalCount++;
+ scannerSignalActive = true;
+ const freqStr = data.frequency.toFixed(3);
+
+ const signalCount = document.getElementById('scannerSignalCount');
+ if (signalCount) signalCount.textContent = scannerSignalCount;
+ const mainSignalCount = document.getElementById('mainSignalCount');
+ if (mainSignalCount) mainSignalCount.textContent = scannerSignalCount;
+
+ // Update sidebar
+ updateScannerDisplay('SIGNAL FOUND', 'var(--accent-green)');
+ const signalPanel = document.getElementById('scannerSignalPanel');
+ if (signalPanel) signalPanel.style.display = 'block';
+ const statusText = document.getElementById('scannerStatusText');
+ if (statusText) statusText.textContent = 'Listening to signal...';
+
+ // Update main display
+ const mainModeLabel = document.getElementById('mainScannerModeLabel');
+ if (mainModeLabel) mainModeLabel.textContent = 'SIGNAL DETECTED';
+
+ const mainFreq = document.getElementById('mainScannerFreq');
+ if (mainFreq) mainFreq.style.color = 'var(--accent-green)';
+
+ const mainAnim = document.getElementById('mainScannerAnimation');
+ if (mainAnim) mainAnim.style.display = 'none';
+
+ const mainSignalAlert = document.getElementById('mainSignalAlert');
+ if (mainSignalAlert) mainSignalAlert.style.display = 'block';
+
+ // Start audio playback for the detected signal
+ if (data.audio_streaming) {
+ const scannerAudio = document.getElementById('scannerAudioPlayer');
+ if (scannerAudio) {
+ // Pass the signal frequency and modulation to getStreamUrl
+ const streamUrl = getStreamUrl(data.frequency, data.modulation);
+ console.log('[SCANNER] Starting audio for signal:', data.frequency, 'MHz');
+ scannerAudio.src = streamUrl;
+ scannerAudio.play().catch(e => console.warn('[SCANNER] Audio autoplay blocked:', e));
+ }
+ }
+
+ // Add to sidebar recent signals
+ if (typeof addSidebarRecentSignal === 'function') {
+ addSidebarRecentSignal(data.frequency, data.modulation);
+ }
+
+ addScannerLogEntry('SIGNAL FOUND', `${freqStr} MHz (${data.modulation.toUpperCase()})`, 'signal');
+ addSignalHit(data);
+
+ if (typeof showNotification === 'function') {
+ showNotification('Signal Found!', `${freqStr} MHz - Audio streaming`);
+ }
+}
+
+function handleSignalLost(data) {
+ scannerSignalActive = false;
+
+ // Update sidebar
+ updateScannerDisplay('SCANNING', 'var(--accent-cyan)');
+ const signalPanel = document.getElementById('scannerSignalPanel');
+ if (signalPanel) signalPanel.style.display = 'none';
+ const statusText = document.getElementById('scannerStatusText');
+ if (statusText) statusText.textContent = 'Scanning...';
+
+ // Update main display
+ const mainModeLabel = document.getElementById('mainScannerModeLabel');
+ if (mainModeLabel) mainModeLabel.textContent = 'SCANNING';
+
+ const mainFreq = document.getElementById('mainScannerFreq');
+ if (mainFreq) mainFreq.style.color = 'var(--accent-cyan)';
+
+ const mainAnim = document.getElementById('mainScannerAnimation');
+ if (mainAnim) mainAnim.style.display = 'block';
+
+ const mainSignalAlert = document.getElementById('mainSignalAlert');
+ if (mainSignalAlert) mainSignalAlert.style.display = 'none';
+
+ // Stop audio
+ const scannerAudio = document.getElementById('scannerAudioPlayer');
+ if (scannerAudio) {
+ scannerAudio.pause();
+ scannerAudio.src = '';
+ }
+
+ const logType = data.type === 'signal_skipped' ? 'info' : 'info';
+ const logTitle = data.type === 'signal_skipped' ? 'Signal skipped' : 'Signal lost';
+ addScannerLogEntry(logTitle, `${data.frequency.toFixed(3)} MHz`, logType);
+}
+
+function updateScannerDisplay(mode, color) {
+ const modeLabel = document.getElementById('scannerModeLabel');
+ if (modeLabel) {
+ modeLabel.textContent = mode;
+ modeLabel.style.color = color;
+ }
+
+ const currentFreq = document.getElementById('scannerCurrentFreq');
+ if (currentFreq) currentFreq.style.color = color;
+
+ const mainModeLabel = document.getElementById('mainScannerModeLabel');
+ if (mainModeLabel) mainModeLabel.textContent = mode;
+
+ const mainFreq = document.getElementById('mainScannerFreq');
+ if (mainFreq) mainFreq.style.color = color;
+}
+
+// ============== SCANNER LOG ==============
+
+function addScannerLogEntry(title, detail, type = 'info') {
+ const now = new Date();
+ const timestamp = now.toLocaleTimeString();
+ const entry = { timestamp, title, detail, type };
+ scannerLogEntries.unshift(entry);
+
+ if (scannerLogEntries.length > 100) {
+ scannerLogEntries.pop();
+ }
+
+ // Color based on type
+ const getTypeColor = (t) => {
+ switch(t) {
+ case 'signal': return 'var(--accent-green)';
+ case 'error': return 'var(--accent-red)';
+ default: return 'var(--text-secondary)';
+ }
+ };
+
+ // Update sidebar log
+ const sidebarLog = document.getElementById('scannerLog');
+ if (sidebarLog) {
+ sidebarLog.innerHTML = scannerLogEntries.slice(0, 20).map(e =>
+ `
+ [${e.timestamp}]
+ ${e.title} ${e.detail}
+
`
+ ).join('');
+ }
+
+ // Update main activity log
+ const activityLog = document.getElementById('scannerActivityLog');
+ if (activityLog) {
+ const getBorderColor = (t) => {
+ switch(t) {
+ case 'signal': return 'var(--accent-green)';
+ case 'error': return 'var(--accent-red)';
+ default: return 'var(--border-color)';
+ }
+ };
+ activityLog.innerHTML = scannerLogEntries.slice(0, 50).map(e =>
+ `
+ [${e.timestamp}]
+ ${e.title}
+ ${e.detail}
+
`
+ ).join('');
+ }
+}
+
+function addSignalHit(data) {
+ const tbody = document.getElementById('scannerHitsBody');
+ if (!tbody) return;
+
+ const now = Date.now();
+ const freqKey = data.frequency.toFixed(3);
+
+ // Check for duplicate
+ if (recentSignalHits.has(freqKey)) {
+ const lastHit = recentSignalHits.get(freqKey);
+ if (now - lastHit < 5000) return;
+ }
+ recentSignalHits.set(freqKey, now);
+
+ // Clean up old entries
+ for (const [freq, time] of recentSignalHits) {
+ if (now - time > 30000) {
+ recentSignalHits.delete(freq);
+ }
+ }
+
+ const timestamp = new Date().toLocaleTimeString();
+
+ if (tbody.innerHTML.includes('No signals detected')) {
+ tbody.innerHTML = '';
+ }
+
+ const mod = data.modulation || 'fm';
+ const row = document.createElement('tr');
+ row.style.borderBottom = '1px solid var(--border-color)';
+ row.innerHTML = `
+ ${timestamp}
+ ${data.frequency.toFixed(3)}
+ ${mod.toUpperCase()}
+
+ Listen
+
+ `;
+ tbody.insertBefore(row, tbody.firstChild);
+
+ while (tbody.children.length > 50) {
+ tbody.removeChild(tbody.lastChild);
+ }
+
+ const hitCount = document.getElementById('scannerHitCount');
+ if (hitCount) hitCount.textContent = `${tbody.children.length} signals found`;
+}
+
+function clearScannerLog() {
+ scannerLogEntries = [];
+ scannerSignalCount = 0;
+ scannerFreqsScanned = 0;
+ scannerCycles = 0;
+ recentSignalHits.clear();
+
+ const signalCount = document.getElementById('scannerSignalCount');
+ if (signalCount) signalCount.textContent = '0';
+
+ const mainSignalCount = document.getElementById('mainSignalCount');
+ if (mainSignalCount) mainSignalCount.textContent = '0';
+
+ const mainFreqsScanned = document.getElementById('mainFreqsScanned');
+ if (mainFreqsScanned) mainFreqsScanned.textContent = '0';
+
+ const mainScanCycles = document.getElementById('mainScanCycles');
+ if (mainScanCycles) mainScanCycles.textContent = '0';
+
+ const sidebarLog = document.getElementById('scannerLog');
+ if (sidebarLog) sidebarLog.innerHTML = 'Scanner activity will appear here...
';
+
+ const activityLog = document.getElementById('scannerActivityLog');
+ if (activityLog) activityLog.innerHTML = 'Waiting for scanner to start...
';
+
+ const hitsBody = document.getElementById('scannerHitsBody');
+ if (hitsBody) hitsBody.innerHTML = 'No signals detected ';
+
+ const hitCount = document.getElementById('scannerHitCount');
+ if (hitCount) hitCount.textContent = '0 signals found';
+}
+
+function exportScannerLog() {
+ if (scannerLogEntries.length === 0) {
+ if (typeof showNotification === 'function') {
+ showNotification('Export', 'No log entries to export');
+ }
+ return;
+ }
+
+ const csv = 'Timestamp,Event,Details\n' + scannerLogEntries.map(e =>
+ `"${e.timestamp}","${e.title}","${e.detail}"`
+ ).join('\n');
+
+ const blob = new Blob([csv], { type: 'text/csv' });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = `scanner_log_${new Date().toISOString().slice(0, 10)}.csv`;
+ a.click();
+ URL.revokeObjectURL(url);
+
+ if (typeof showNotification === 'function') {
+ showNotification('Export', 'Log exported to CSV');
+ }
+}
+
+// ============== AUDIO TOOLS CHECK ==============
+
+function checkAudioTools() {
+ fetch('/listening/tools')
+ .then(r => r.json())
+ .then(data => {
+ audioToolsAvailable.rtl_fm = data.rtl_fm;
+ audioToolsAvailable.ffmpeg = data.ffmpeg;
+
+ // Only rtl_fm/rx_fm + ffmpeg are required for direct streaming
+ const warnings = [];
+ if (!data.rtl_fm && !data.rx_fm) {
+ warnings.push('rtl_fm/rx_fm not found - install rtl-sdr or soapysdr-tools');
+ }
+ if (!data.ffmpeg) {
+ warnings.push('ffmpeg not found - install: brew install ffmpeg (macOS) or apt install ffmpeg (Linux)');
+ }
+
+ const warningDiv = document.getElementById('audioToolsWarning');
+ const warningText = document.getElementById('audioToolsWarningText');
+ if (warningDiv) {
+ if (warnings.length > 0) {
+ warningText.innerHTML = warnings.join(' ');
+ warningDiv.style.display = 'block';
+ document.getElementById('audioStartBtn').disabled = true;
+ document.getElementById('audioStartBtn').style.opacity = '0.5';
+ } else {
+ warningDiv.style.display = 'none';
+ document.getElementById('audioStartBtn').disabled = false;
+ document.getElementById('audioStartBtn').style.opacity = '1';
+ }
+ }
+ })
+ .catch(() => {});
+}
+
+// ============== AUDIO PRESETS ==============
+
+function applyAudioPreset() {
+ const preset = document.getElementById('audioPreset').value;
+ const freqInput = document.getElementById('audioFrequency');
+ const modSelect = document.getElementById('audioModulation');
+
+ if (audioPresets[preset]) {
+ freqInput.value = audioPresets[preset].freq;
+ modSelect.value = audioPresets[preset].mod;
+ }
+}
+
+// ============== AUDIO CONTROLS ==============
+
+function toggleAudio() {
+ if (isAudioPlaying) {
+ stopAudio();
+ } else {
+ startAudio();
+ }
+}
+
+function startAudio() {
+ const frequency = parseFloat(document.getElementById('audioFrequency').value);
+ const modulation = document.getElementById('audioModulation').value;
+ const squelch = parseInt(document.getElementById('audioSquelch').value);
+ const gain = parseInt(document.getElementById('audioGain').value);
+ const device = getSelectedDevice();
+
+ if (isNaN(frequency) || frequency <= 0) {
+ if (typeof showNotification === 'function') {
+ showNotification('Audio Error', 'Invalid frequency');
+ }
+ return;
+ }
+
+ // Check if device is in use
+ if (typeof getDeviceInUseBy === 'function') {
+ const usedBy = getDeviceInUseBy(device);
+ if (usedBy && usedBy !== 'audio') {
+ if (typeof showNotification === 'function') {
+ showNotification('SDR In Use', `Device ${device} is being used by ${usedBy.toUpperCase()}.`);
+ }
+ return;
+ }
+ }
+
+ document.getElementById('audioStatus').textContent = 'STARTING...';
+ document.getElementById('audioStatus').style.color = 'var(--accent-orange)';
+
+ // Use direct streaming - no Icecast needed
+ if (typeof reserveDevice === 'function') reserveDevice(device, 'audio');
+ isAudioPlaying = true;
+
+ // Build direct stream URL with parameters
+ const streamUrl = `/listening/audio/stream?freq=${frequency}&mod=${modulation}&squelch=${squelch}&gain=${gain}&t=${Date.now()}`;
+ console.log('Connecting to direct stream:', streamUrl);
+
+ // Start browser audio playback
+ const audioPlayer = document.getElementById('audioPlayer');
+ audioPlayer.src = streamUrl;
+ audioPlayer.volume = document.getElementById('audioVolume').value / 100;
+
+ initAudioVisualizer();
+
+ audioPlayer.onplaying = () => {
+ document.getElementById('audioStatus').textContent = 'STREAMING';
+ document.getElementById('audioStatus').style.color = 'var(--accent-green)';
+ };
+
+ audioPlayer.onerror = (e) => {
+ console.error('Audio player error:', e);
+ document.getElementById('audioStatus').textContent = 'ERROR';
+ document.getElementById('audioStatus').style.color = 'var(--accent-red)';
+ if (typeof showNotification === 'function') {
+ showNotification('Audio Error', 'Stream error - check SDR connection');
+ }
+ };
+
+ audioPlayer.play().catch(e => {
+ console.warn('Audio autoplay blocked:', e);
+ if (typeof showNotification === 'function') {
+ showNotification('Audio Ready', 'Click Play button again if audio does not start');
+ }
+ });
+
+ document.getElementById('audioStartBtn').textContent = '⏹ Stop Audio';
+ document.getElementById('audioStartBtn').classList.add('active');
+ document.getElementById('audioTunedFreq').textContent = frequency.toFixed(2) + ' MHz (' + modulation.toUpperCase() + ')';
+ document.getElementById('audioDeviceStatus').textContent = 'SDR ' + device;
+
+ if (typeof showNotification === 'function') {
+ showNotification('Audio Started', `Streaming ${frequency} MHz to browser`);
+ }
+}
+
+async function stopAudio() {
+ stopAudioVisualizer();
+
+ const audioPlayer = document.getElementById('audioPlayer');
+ if (audioPlayer) {
+ audioPlayer.pause();
+ audioPlayer.src = '';
+ }
+
+ try {
+ await fetch('/listening/audio/stop', { method: 'POST' });
+ if (typeof releaseDevice === 'function') releaseDevice('audio');
+ isAudioPlaying = false;
+ document.getElementById('audioStartBtn').textContent = '▶ Play Audio';
+ document.getElementById('audioStartBtn').classList.remove('active');
+ document.getElementById('audioStatus').textContent = 'STOPPED';
+ document.getElementById('audioStatus').style.color = 'var(--text-muted)';
+ document.getElementById('audioDeviceStatus').textContent = '--';
+ } catch (e) {
+ console.error('Error stopping audio:', e);
+ }
+}
+
+function updateAudioVolume() {
+ const audioPlayer = document.getElementById('audioPlayer');
+ if (audioPlayer) {
+ audioPlayer.volume = document.getElementById('audioVolume').value / 100;
+ }
+}
+
+function audioFreqUp() {
+ const input = document.getElementById('audioFrequency');
+ const mod = document.getElementById('audioModulation').value;
+ const step = (mod === 'wfm') ? 0.2 : 0.025;
+ input.value = (parseFloat(input.value) + step).toFixed(2);
+ if (isAudioPlaying) {
+ tuneAudioFrequency(parseFloat(input.value));
+ }
+}
+
+function audioFreqDown() {
+ const input = document.getElementById('audioFrequency');
+ const mod = document.getElementById('audioModulation').value;
+ const step = (mod === 'wfm') ? 0.2 : 0.025;
+ input.value = (parseFloat(input.value) - step).toFixed(2);
+ if (isAudioPlaying) {
+ tuneAudioFrequency(parseFloat(input.value));
+ }
+}
+
+function tuneAudioFrequency(frequency) {
+ fetch('/listening/audio/tune', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ frequency: frequency })
+ })
+ .then(r => r.json())
+ .then(data => {
+ if (data.status === 'tuned') {
+ document.getElementById('audioTunedFreq').textContent = frequency.toFixed(2) + ' MHz';
+ }
+ })
+ .catch(() => {
+ stopAudio();
+ setTimeout(startAudio, 300);
+ });
+}
+
+async function tuneToFrequency(freq, mod) {
+ try {
+ // Stop scanner if running
+ if (isScannerRunning) {
+ stopScanner();
+ await new Promise(resolve => setTimeout(resolve, 300));
+ }
+
+ // Update frequency input
+ const freqInput = document.getElementById('radioScanStart');
+ if (freqInput) {
+ freqInput.value = freq.toFixed(1);
+ }
+
+ // Update modulation if provided
+ if (mod) {
+ setModulation(mod);
+ }
+
+ // Update tuning dial (silent to avoid duplicate events)
+ const mainTuningDial = document.getElementById('mainTuningDial');
+ if (mainTuningDial && mainTuningDial._dial) {
+ mainTuningDial._dial.setValue(freq, true);
+ }
+
+ // Update frequency display
+ const mainFreq = document.getElementById('mainScannerFreq');
+ if (mainFreq) {
+ mainFreq.textContent = freq.toFixed(3);
+ }
+
+ // Start listening immediately
+ await startDirectListenImmediate();
+
+ if (typeof showNotification === 'function') {
+ showNotification('Tuned', `Now listening to ${freq.toFixed(3)} MHz (${(mod || currentModulation).toUpperCase()})`);
+ }
+ } catch (err) {
+ console.error('Error tuning to frequency:', err);
+ if (typeof showNotification === 'function') {
+ showNotification('Tune Error', 'Failed to tune to frequency: ' + err.message);
+ }
+ }
+}
+
+// ============== AUDIO VISUALIZER ==============
+
+function initAudioVisualizer() {
+ const audioPlayer = document.getElementById('audioPlayer');
+ if (!audioPlayer) return;
+
+ if (!visualizerContext) {
+ visualizerContext = new (window.AudioContext || window.webkitAudioContext)();
+ }
+
+ if (visualizerContext.state === 'suspended') {
+ visualizerContext.resume();
+ }
+
+ if (!visualizerSource) {
+ try {
+ visualizerSource = visualizerContext.createMediaElementSource(audioPlayer);
+ visualizerAnalyser = visualizerContext.createAnalyser();
+ visualizerAnalyser.fftSize = 256;
+ visualizerAnalyser.smoothingTimeConstant = 0.7;
+
+ visualizerSource.connect(visualizerAnalyser);
+ visualizerAnalyser.connect(visualizerContext.destination);
+ } catch (e) {
+ console.warn('Could not create audio source:', e);
+ return;
+ }
+ }
+
+ const container = document.getElementById('audioVisualizerContainer');
+ if (container) container.style.display = 'block';
+
+ drawAudioVisualizer();
+}
+
+function drawAudioVisualizer() {
+ if (!visualizerAnalyser) return;
+
+ const canvas = document.getElementById('audioSpectrumCanvas');
+ if (!canvas) return;
+
+ const ctx = canvas.getContext('2d');
+ const bufferLength = visualizerAnalyser.frequencyBinCount;
+ const dataArray = new Uint8Array(bufferLength);
+
+ function draw() {
+ visualizerAnimationId = requestAnimationFrame(draw);
+
+ visualizerAnalyser.getByteFrequencyData(dataArray);
+
+ let sum = 0;
+ for (let i = 0; i < bufferLength; i++) {
+ sum += dataArray[i];
+ }
+ const average = sum / bufferLength;
+ const levelPercent = (average / 255) * 100;
+
+ if (levelPercent > peakLevel) {
+ peakLevel = levelPercent;
+ } else {
+ peakLevel *= peakDecay;
+ }
+
+ const meterFill = document.getElementById('audioSignalMeter');
+ const meterPeak = document.getElementById('audioSignalPeak');
+ const meterValue = document.getElementById('audioSignalValue');
+
+ if (meterFill) meterFill.style.width = levelPercent + '%';
+ if (meterPeak) meterPeak.style.left = Math.min(peakLevel, 100) + '%';
+
+ const db = average > 0 ? Math.round(20 * Math.log10(average / 255)) : -60;
+ if (meterValue) meterValue.textContent = db + ' dB';
+
+ ctx.fillStyle = 'rgba(0, 0, 0, 0.3)';
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
+
+ const barWidth = canvas.width / bufferLength * 2.5;
+ let x = 0;
+
+ for (let i = 0; i < bufferLength; i++) {
+ const barHeight = (dataArray[i] / 255) * canvas.height;
+ const hue = 200 - (i / bufferLength) * 60;
+ const lightness = 40 + (dataArray[i] / 255) * 30;
+ ctx.fillStyle = `hsl(${hue}, 80%, ${lightness}%)`;
+ ctx.fillRect(x, canvas.height - barHeight, barWidth - 1, barHeight);
+ x += barWidth;
+ }
+
+ ctx.fillStyle = 'rgba(255, 255, 255, 0.3)';
+ ctx.font = '8px JetBrains Mono';
+ ctx.fillText('0', 2, canvas.height - 2);
+ ctx.fillText('4kHz', canvas.width / 4, canvas.height - 2);
+ ctx.fillText('8kHz', canvas.width / 2, canvas.height - 2);
+ }
+
+ draw();
+}
+
+function stopAudioVisualizer() {
+ if (visualizerAnimationId) {
+ cancelAnimationFrame(visualizerAnimationId);
+ visualizerAnimationId = null;
+ }
+
+ const meterFill = document.getElementById('audioSignalMeter');
+ const meterPeak = document.getElementById('audioSignalPeak');
+ const meterValue = document.getElementById('audioSignalValue');
+
+ if (meterFill) meterFill.style.width = '0%';
+ if (meterPeak) meterPeak.style.left = '0%';
+ if (meterValue) meterValue.textContent = '-∞ dB';
+
+ peakLevel = 0;
+
+ const container = document.getElementById('audioVisualizerContainer');
+ if (container) container.style.display = 'none';
+}
+
+// ============== RADIO KNOB CONTROLS ==============
+
+/**
+ * Update scanner config on the backend (for live updates while scanning)
+ */
+function updateScannerConfig(config) {
+ if (!isScannerRunning) return;
+ fetch('/listening/scanner/config', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(config)
+ }).catch(() => {});
+}
+
+/**
+ * Initialize radio knob controls and wire them to scanner parameters
+ */
+function initRadioKnobControls() {
+ // Squelch knob
+ const squelchKnob = document.getElementById('radioSquelchKnob');
+ if (squelchKnob) {
+ squelchKnob.addEventListener('knobchange', function(e) {
+ const value = Math.round(e.detail.value);
+ const valueDisplay = document.getElementById('radioSquelchValue');
+ if (valueDisplay) valueDisplay.textContent = value;
+ // Sync with scanner
+ updateScannerConfig({ squelch: value });
+ // Restart stream if direct listening (squelch requires restart)
+ if (isDirectListening) {
+ startDirectListen();
+ }
+ });
+ }
+
+ // Gain knob
+ const gainKnob = document.getElementById('radioGainKnob');
+ if (gainKnob) {
+ gainKnob.addEventListener('knobchange', function(e) {
+ const value = Math.round(e.detail.value);
+ const valueDisplay = document.getElementById('radioGainValue');
+ if (valueDisplay) valueDisplay.textContent = value;
+ // Sync with scanner
+ updateScannerConfig({ gain: value });
+ // Restart stream if direct listening (gain requires restart)
+ if (isDirectListening) {
+ startDirectListen();
+ }
+ });
+ }
+
+ // Volume knob - controls scanner audio player volume
+ const volumeKnob = document.getElementById('radioVolumeKnob');
+ if (volumeKnob) {
+ volumeKnob.addEventListener('knobchange', function(e) {
+ const audioPlayer = document.getElementById('scannerAudioPlayer');
+ if (audioPlayer) {
+ audioPlayer.volume = e.detail.value / 100;
+ }
+ const manualPlayer = document.getElementById('audioPlayer');
+ if (manualPlayer) {
+ manualPlayer.volume = e.detail.value / 100;
+ }
+ // Update knob value display
+ const valueDisplay = document.getElementById('radioVolumeValue');
+ if (valueDisplay) valueDisplay.textContent = Math.round(e.detail.value);
+ });
+ }
+
+ // Main Tuning dial - updates frequency display and inputs
+ const mainTuningDial = document.getElementById('mainTuningDial');
+ if (mainTuningDial) {
+ mainTuningDial.addEventListener('knobchange', function(e) {
+ const freq = e.detail.value;
+ // Update main frequency display
+ const mainFreq = document.getElementById('mainScannerFreq');
+ if (mainFreq) {
+ mainFreq.textContent = freq.toFixed(3);
+ }
+ // Update radio scan start input
+ const startFreqInput = document.getElementById('radioScanStart');
+ if (startFreqInput) {
+ startFreqInput.value = freq.toFixed(1);
+ }
+ // Update sidebar frequency input
+ const sidebarFreq = document.getElementById('audioFrequency');
+ if (sidebarFreq) {
+ sidebarFreq.value = freq.toFixed(3);
+ }
+ // If currently listening, retune to new frequency
+ if (isDirectListening) {
+ startDirectListen();
+ }
+ });
+ }
+
+ // Legacy tuning dial support
+ const tuningDial = document.getElementById('tuningDial');
+ if (tuningDial) {
+ tuningDial.addEventListener('knobchange', function(e) {
+ const mainFreq = document.getElementById('mainScannerFreq');
+ if (mainFreq) mainFreq.textContent = e.detail.value.toFixed(3);
+ const startFreqInput = document.getElementById('radioScanStart');
+ if (startFreqInput) startFreqInput.value = e.detail.value.toFixed(1);
+ // If currently listening, retune to new frequency
+ if (isDirectListening) {
+ startDirectListen();
+ }
+ });
+ }
+
+ // Sync radio scan range inputs with sidebar
+ const radioScanStart = document.getElementById('radioScanStart');
+ const radioScanEnd = document.getElementById('radioScanEnd');
+
+ if (radioScanStart) {
+ radioScanStart.addEventListener('change', function() {
+ const sidebarStart = document.getElementById('scanStartFreq');
+ if (sidebarStart) sidebarStart.value = this.value;
+ // Restart stream if direct listening
+ if (isDirectListening) {
+ startDirectListen();
+ }
+ });
+ }
+
+ if (radioScanEnd) {
+ radioScanEnd.addEventListener('change', function() {
+ const sidebarEnd = document.getElementById('scanEndFreq');
+ if (sidebarEnd) sidebarEnd.value = this.value;
+ });
+ }
+}
+
+/**
+ * Set modulation mode (called from HTML onclick)
+ */
+function setModulation(mod) {
+ // Update sidebar select
+ const modSelect = document.getElementById('scanModulation');
+ if (modSelect) modSelect.value = mod;
+
+ // Update audio modulation select
+ const audioMod = document.getElementById('audioModulation');
+ if (audioMod) audioMod.value = mod;
+
+ // Update button states in radio panel
+ document.querySelectorAll('#modBtnBank .radio-btn').forEach(btn => {
+ btn.classList.toggle('active', btn.dataset.mod === mod);
+ });
+
+ // Update main display badge
+ const mainBadge = document.getElementById('mainScannerMod');
+ if (mainBadge) mainBadge.textContent = mod.toUpperCase();
+}
+
+/**
+ * Set band preset (called from HTML onclick)
+ */
+function setBand(band) {
+ const preset = scannerPresets[band];
+ if (!preset) return;
+
+ // Update button states
+ document.querySelectorAll('#bandBtnBank .radio-btn').forEach(btn => {
+ btn.classList.toggle('active', btn.dataset.band === band);
+ });
+
+ // Update sidebar frequency inputs
+ const sidebarStart = document.getElementById('scanStartFreq');
+ const sidebarEnd = document.getElementById('scanEndFreq');
+ if (sidebarStart) sidebarStart.value = preset.start;
+ if (sidebarEnd) sidebarEnd.value = preset.end;
+
+ // Update radio panel frequency inputs
+ const radioStart = document.getElementById('radioScanStart');
+ const radioEnd = document.getElementById('radioScanEnd');
+ if (radioStart) radioStart.value = preset.start;
+ if (radioEnd) radioEnd.value = preset.end;
+
+ // Update tuning dial range and value (silent to avoid triggering restart)
+ const tuningDial = document.getElementById('tuningDial');
+ if (tuningDial && tuningDial._dial) {
+ tuningDial._dial.min = preset.start;
+ tuningDial._dial.max = preset.end;
+ tuningDial._dial.setValue(preset.start, true);
+ }
+
+ // Update main frequency display
+ const mainFreq = document.getElementById('mainScannerFreq');
+ if (mainFreq) mainFreq.textContent = preset.start.toFixed(3);
+
+ // Update modulation
+ setModulation(preset.mod);
+
+ // Update main range display if scanning
+ const rangeStart = document.getElementById('mainRangeStart');
+ const rangeEnd = document.getElementById('mainRangeEnd');
+ if (rangeStart) rangeStart.textContent = preset.start;
+ if (rangeEnd) rangeEnd.textContent = preset.end;
+
+ // Store for scanner use
+ scannerStartFreq = preset.start;
+ scannerEndFreq = preset.end;
+}
+
+// ============== SYNTHESIZER VISUALIZATION ==============
+
+let synthAnimationId = null;
+let synthCanvas = null;
+let synthCtx = null;
+let synthBars = [];
+const SYNTH_BAR_COUNT = 32;
+
+function initSynthesizer() {
+ synthCanvas = document.getElementById('synthesizerCanvas');
+ if (!synthCanvas) return;
+
+ // Set canvas size
+ const rect = synthCanvas.parentElement.getBoundingClientRect();
+ synthCanvas.width = rect.width - 20;
+ synthCanvas.height = 60;
+
+ synthCtx = synthCanvas.getContext('2d');
+
+ // Initialize bar heights
+ for (let i = 0; i < SYNTH_BAR_COUNT; i++) {
+ synthBars[i] = { height: 0, targetHeight: 0, velocity: 0 };
+ }
+
+ drawSynthesizer();
+}
+
+function drawSynthesizer() {
+ if (!synthCtx || !synthCanvas) return;
+
+ const width = synthCanvas.width;
+ const height = synthCanvas.height;
+ const barWidth = (width / SYNTH_BAR_COUNT) - 2;
+
+ // Clear canvas
+ synthCtx.fillStyle = 'rgba(0, 0, 0, 0.3)';
+ synthCtx.fillRect(0, 0, width, height);
+
+ // Determine activity level based on state
+ let activityLevel = 0;
+ if (isScannerRunning && !isScannerPaused) {
+ activityLevel = scannerSignalActive ? 0.9 : 0.4;
+ } else if (isDirectListening) {
+ activityLevel = 0.7;
+ }
+
+ // Update bar targets
+ for (let i = 0; i < SYNTH_BAR_COUNT; i++) {
+ if (activityLevel > 0) {
+ // Create wave-like pattern with some randomness
+ const wave = Math.sin((Date.now() / 200) + (i * 0.3)) * 0.3;
+ const random = Math.random() * 0.4;
+ const centerBoost = 1 - Math.abs((i - SYNTH_BAR_COUNT / 2) / (SYNTH_BAR_COUNT / 2)) * 0.5;
+ synthBars[i].targetHeight = (wave + random + 0.3) * activityLevel * centerBoost * height;
+ } else {
+ // Idle state - minimal activity
+ synthBars[i].targetHeight = (Math.sin((Date.now() / 500) + (i * 0.5)) * 0.1 + 0.1) * height * 0.3;
+ }
+
+ // Smooth animation
+ const diff = synthBars[i].targetHeight - synthBars[i].height;
+ synthBars[i].velocity += diff * 0.1;
+ synthBars[i].velocity *= 0.8;
+ synthBars[i].height += synthBars[i].velocity;
+ synthBars[i].height = Math.max(2, Math.min(height - 4, synthBars[i].height));
+ }
+
+ // Draw bars
+ for (let i = 0; i < SYNTH_BAR_COUNT; i++) {
+ const x = i * (barWidth + 2) + 1;
+ const barHeight = synthBars[i].height;
+ const y = (height - barHeight) / 2;
+
+ // Color gradient based on height and state
+ let hue, saturation, lightness;
+ if (scannerSignalActive) {
+ hue = 120; // Green for signal
+ saturation = 80;
+ lightness = 40 + (barHeight / height) * 30;
+ } else if (isScannerRunning || isDirectListening) {
+ hue = 190 + (i / SYNTH_BAR_COUNT) * 30; // Cyan to blue
+ saturation = 80;
+ lightness = 35 + (barHeight / height) * 25;
+ } else {
+ hue = 200;
+ saturation = 50;
+ lightness = 25 + (barHeight / height) * 15;
+ }
+
+ const gradient = synthCtx.createLinearGradient(x, y, x, y + barHeight);
+ gradient.addColorStop(0, `hsla(${hue}, ${saturation}%, ${lightness + 20}%, 0.9)`);
+ gradient.addColorStop(0.5, `hsla(${hue}, ${saturation}%, ${lightness}%, 1)`);
+ gradient.addColorStop(1, `hsla(${hue}, ${saturation}%, ${lightness + 20}%, 0.9)`);
+
+ synthCtx.fillStyle = gradient;
+ synthCtx.fillRect(x, y, barWidth, barHeight);
+
+ // Add glow effect for active bars
+ if (barHeight > height * 0.5 && activityLevel > 0.5) {
+ synthCtx.shadowColor = `hsla(${hue}, ${saturation}%, 60%, 0.5)`;
+ synthCtx.shadowBlur = 8;
+ synthCtx.fillRect(x, y, barWidth, barHeight);
+ synthCtx.shadowBlur = 0;
+ }
+ }
+
+ // Draw center line
+ synthCtx.strokeStyle = 'rgba(0, 212, 255, 0.2)';
+ synthCtx.lineWidth = 1;
+ synthCtx.beginPath();
+ synthCtx.moveTo(0, height / 2);
+ synthCtx.lineTo(width, height / 2);
+ synthCtx.stroke();
+
+ synthAnimationId = requestAnimationFrame(drawSynthesizer);
+}
+
+function stopSynthesizer() {
+ if (synthAnimationId) {
+ cancelAnimationFrame(synthAnimationId);
+ synthAnimationId = null;
+ }
+}
+
+// ============== INITIALIZATION ==============
+
+/**
+ * Get the audio stream URL with parameters
+ * Streams directly from Flask - no Icecast needed
+ */
+function getStreamUrl(freq, mod) {
+ const frequency = freq || parseFloat(document.getElementById('radioScanStart')?.value) || 118.0;
+ const modulation = mod || currentModulation || 'am';
+ const squelch = parseInt(document.getElementById('radioSquelchValue')?.textContent) || 30;
+ const gain = parseInt(document.getElementById('radioGainValue')?.textContent) || 40;
+ return `/listening/audio/stream?freq=${frequency}&mod=${modulation}&squelch=${squelch}&gain=${gain}&t=${Date.now()}`;
+}
+
+function initListeningPost() {
+ checkScannerTools();
+ checkAudioTools();
+
+ // WebSocket audio disabled for now - using HTTP streaming
+ // initWebSocketAudio();
+
+ // Initialize synthesizer visualization
+ initSynthesizer();
+
+ // Initialize radio knobs if the component is available
+ if (typeof initRadioKnobs === 'function') {
+ initRadioKnobs();
+ }
+
+ // Connect radio knobs to scanner controls
+ initRadioKnobControls();
+
+ // Step dropdown - sync with scanner when changed
+ const stepSelect = document.getElementById('radioScanStep');
+ if (stepSelect) {
+ stepSelect.addEventListener('change', function() {
+ const step = parseFloat(this.value);
+ console.log('[SCANNER] Step changed to:', step, 'kHz');
+ updateScannerConfig({ step: step });
+ });
+ }
+
+ // Dwell dropdown - sync with scanner when changed
+ const dwellSelect = document.getElementById('radioScanDwell');
+ if (dwellSelect) {
+ dwellSelect.addEventListener('change', function() {
+ const dwell = parseInt(this.value);
+ console.log('[SCANNER] Dwell changed to:', dwell, 's');
+ updateScannerConfig({ dwell_time: dwell });
+ });
+ }
+
+ // Set up audio player error handling
+ const audioPlayer = document.getElementById('audioPlayer');
+ if (audioPlayer) {
+ audioPlayer.addEventListener('error', function(e) {
+ console.warn('Audio player error:', e);
+ if (isAudioPlaying && audioReconnectAttempts < MAX_AUDIO_RECONNECT) {
+ audioReconnectAttempts++;
+ setTimeout(() => {
+ audioPlayer.src = getStreamUrl();
+ audioPlayer.play().catch(() => {});
+ }, 500);
+ }
+ });
+
+ audioPlayer.addEventListener('stalled', function() {
+ if (isAudioPlaying) {
+ audioPlayer.load();
+ audioPlayer.play().catch(() => {});
+ }
+ });
+
+ audioPlayer.addEventListener('playing', function() {
+ audioReconnectAttempts = 0;
+ });
+ }
+}
+
+// Initialize when DOM is ready
+document.addEventListener('DOMContentLoaded', initListeningPost);
+
+// ============== UNIFIED RADIO CONTROLS ==============
+
+/**
+ * Toggle direct listen mode (tune to start frequency and listen)
+ */
+function toggleDirectListen() {
+ console.log('[LISTEN] toggleDirectListen called, isDirectListening:', isDirectListening);
+ if (isDirectListening) {
+ stopDirectListen();
+ } else {
+ // First press - start immediately, don't debounce
+ startDirectListenImmediate();
+ }
+}
+
+// Debounce for startDirectListen
+let listenDebounceTimer = null;
+// Flag to prevent overlapping restart attempts
+let isRestarting = false;
+// Flag indicating another restart is needed after current one finishes
+let restartPending = false;
+// Debounce for frequency tuning (user might be scrolling through)
+// Needs to be long enough for SDR to fully release between restarts
+const TUNE_DEBOUNCE_MS = 600;
+
+/**
+ * Start direct listening - debounced for frequency changes
+ */
+function startDirectListen() {
+ if (listenDebounceTimer) {
+ clearTimeout(listenDebounceTimer);
+ }
+ listenDebounceTimer = setTimeout(async () => {
+ // If already restarting, mark that we need another restart when done
+ if (isRestarting) {
+ console.log('[LISTEN] Restart in progress, will retry after');
+ restartPending = true;
+ return;
+ }
+
+ await _startDirectListenInternal();
+
+ // If another restart was requested during this one, do it now
+ while (restartPending) {
+ restartPending = false;
+ console.log('[LISTEN] Processing pending restart');
+ await _startDirectListenInternal();
+ }
+ }, TUNE_DEBOUNCE_MS);
+}
+
+/**
+ * Start listening immediately (no debounce) - for button press
+ */
+async function startDirectListenImmediate() {
+ if (listenDebounceTimer) {
+ clearTimeout(listenDebounceTimer);
+ listenDebounceTimer = null;
+ }
+ restartPending = false; // Clear any pending
+ if (isRestarting) {
+ console.log('[LISTEN] Waiting for current restart to finish...');
+ // Wait for current restart to complete (max 5 seconds)
+ let waitCount = 0;
+ while (isRestarting && waitCount < 50) {
+ await new Promise(r => setTimeout(r, 100));
+ waitCount++;
+ }
+ }
+ await _startDirectListenInternal();
+}
+
+// ============== WEBSOCKET AUDIO ==============
+
+/**
+ * Initialize WebSocket audio connection
+ */
+function initWebSocketAudio() {
+ if (audioWebSocket && audioWebSocket.readyState === WebSocket.OPEN) {
+ return audioWebSocket;
+ }
+
+ const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
+ const wsUrl = `${protocol}//${window.location.host}/ws/audio`;
+
+ console.log('[WS-AUDIO] Connecting to:', wsUrl);
+ audioWebSocket = new WebSocket(wsUrl);
+ audioWebSocket.binaryType = 'arraybuffer';
+
+ audioWebSocket.onopen = () => {
+ console.log('[WS-AUDIO] Connected');
+ isWebSocketAudio = true;
+ };
+
+ audioWebSocket.onclose = () => {
+ console.log('[WS-AUDIO] Disconnected');
+ isWebSocketAudio = false;
+ audioWebSocket = null;
+ };
+
+ audioWebSocket.onerror = (e) => {
+ console.error('[WS-AUDIO] Error:', e);
+ isWebSocketAudio = false;
+ };
+
+ audioWebSocket.onmessage = (event) => {
+ if (typeof event.data === 'string') {
+ // JSON message (status updates)
+ try {
+ const msg = JSON.parse(event.data);
+ console.log('[WS-AUDIO] Status:', msg);
+ if (msg.status === 'error') {
+ addScannerLogEntry('Audio error: ' + msg.message, '', 'error');
+ }
+ } catch (e) {}
+ } else {
+ // Binary data (audio)
+ handleWebSocketAudioData(event.data);
+ }
+ };
+
+ return audioWebSocket;
+}
+
+/**
+ * Handle incoming WebSocket audio data
+ */
+function handleWebSocketAudioData(data) {
+ const audioPlayer = document.getElementById('scannerAudioPlayer');
+ if (!audioPlayer) return;
+
+ // Use MediaSource API to stream audio
+ if (!audioPlayer.msSource) {
+ setupMediaSource(audioPlayer);
+ }
+
+ if (audioPlayer.sourceBuffer && !audioPlayer.sourceBuffer.updating) {
+ try {
+ audioPlayer.sourceBuffer.appendBuffer(new Uint8Array(data));
+ } catch (e) {
+ // Buffer full or other error, skip this chunk
+ }
+ } else {
+ // Queue data for later
+ audioQueue.push(new Uint8Array(data));
+ if (audioQueue.length > 50) audioQueue.shift(); // Prevent memory buildup
+ }
+}
+
+/**
+ * Setup MediaSource for streaming audio
+ */
+function setupMediaSource(audioPlayer) {
+ if (!window.MediaSource) {
+ console.warn('[WS-AUDIO] MediaSource not supported');
+ return;
+ }
+
+ const mediaSource = new MediaSource();
+ audioPlayer.src = URL.createObjectURL(mediaSource);
+ audioPlayer.msSource = mediaSource;
+
+ mediaSource.addEventListener('sourceopen', () => {
+ try {
+ const sourceBuffer = mediaSource.addSourceBuffer('audio/mpeg');
+ audioPlayer.sourceBuffer = sourceBuffer;
+
+ sourceBuffer.addEventListener('updateend', () => {
+ // Process queued data
+ if (audioQueue.length > 0 && !sourceBuffer.updating) {
+ try {
+ sourceBuffer.appendBuffer(audioQueue.shift());
+ } catch (e) {}
+ }
+ });
+ } catch (e) {
+ console.error('[WS-AUDIO] Failed to create source buffer:', e);
+ }
+ });
+}
+
+/**
+ * Send command over WebSocket
+ */
+function sendWebSocketCommand(cmd, config = {}) {
+ if (!audioWebSocket || audioWebSocket.readyState !== WebSocket.OPEN) {
+ initWebSocketAudio();
+ // Wait for connection and retry
+ setTimeout(() => sendWebSocketCommand(cmd, config), 500);
+ return;
+ }
+
+ audioWebSocket.send(JSON.stringify({ cmd, config }));
+}
+
+async function _startDirectListenInternal() {
+ console.log('[LISTEN] _startDirectListenInternal called');
+
+ // Prevent overlapping restarts
+ if (isRestarting) {
+ console.log('[LISTEN] Already restarting, skipping');
+ return;
+ }
+ isRestarting = true;
+
+ try {
+ if (isScannerRunning) {
+ stopScanner();
+ }
+
+ const freqInput = document.getElementById('radioScanStart');
+ const freq = freqInput ? parseFloat(freqInput.value) : 118.0;
+ const squelch = parseInt(document.getElementById('radioSquelchValue')?.textContent) || 30;
+ const gain = parseInt(document.getElementById('radioGainValue')?.textContent) || 40;
+
+ console.log('[LISTEN] Tuning to:', freq, 'MHz', currentModulation);
+
+ const listenBtn = document.getElementById('radioListenBtn');
+ if (listenBtn) {
+ listenBtn.innerHTML = '⏳ TUNING...';
+ listenBtn.style.background = 'var(--accent-orange)';
+ listenBtn.style.borderColor = 'var(--accent-orange)';
+ }
+
+ const audioPlayer = document.getElementById('scannerAudioPlayer');
+ if (!audioPlayer) {
+ addScannerLogEntry('Audio player not found', '', 'error');
+ updateDirectListenUI(false);
+ return;
+ }
+
+ // Fully reset audio element to clean state
+ audioPlayer.oncanplay = null; // Remove old handler
+ try {
+ audioPlayer.pause();
+ } catch (e) {}
+ audioPlayer.removeAttribute('src');
+ audioPlayer.load(); // Reset the element
+
+ // Start audio on backend (it handles stopping old stream)
+ const response = await fetch('/listening/audio/start', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ frequency: freq,
+ modulation: currentModulation,
+ squelch: squelch,
+ gain: gain
+ })
+ });
+
+ const result = await response.json();
+ console.log('[LISTEN] Backend:', result.status);
+
+ if (result.status !== 'started') {
+ console.error('[LISTEN] Failed:', result.message);
+ addScannerLogEntry('Failed: ' + (result.message || 'Unknown error'), '', 'error');
+ isDirectListening = false;
+ updateDirectListenUI(false);
+ return;
+ }
+
+ // Wait for stream to be ready (backend needs time after restart)
+ await new Promise(r => setTimeout(r, 300));
+
+ // Connect to new stream
+ const streamUrl = `/listening/audio/stream?t=${Date.now()}`;
+ console.log('[LISTEN] Connecting to stream:', streamUrl);
+ audioPlayer.src = streamUrl;
+
+ // Wait for audio to be ready then play
+ audioPlayer.oncanplay = () => {
+ console.log('[LISTEN] Audio can play');
+ audioPlayer.play().catch(e => console.warn('[LISTEN] Autoplay blocked:', e));
+ };
+
+ // Also try to play immediately (some browsers need this)
+ audioPlayer.play().catch(e => {
+ console.log('[LISTEN] Initial play blocked, waiting for canplay');
+ });
+
+ isDirectListening = true;
+ updateDirectListenUI(true, freq);
+ addScannerLogEntry(`${freq.toFixed(3)} MHz (${currentModulation.toUpperCase()})`, '', 'signal');
+
+ } catch (e) {
+ console.error('[LISTEN] Error:', e);
+ addScannerLogEntry('Error: ' + e.message, '', 'error');
+ isDirectListening = false;
+ updateDirectListenUI(false);
+ } finally {
+ isRestarting = false;
+ }
+}
+
+/**
+ * Stop direct listening
+ */
+function stopDirectListen() {
+ console.log('[LISTEN] Stopping');
+
+ // Clear all pending state
+ if (listenDebounceTimer) {
+ clearTimeout(listenDebounceTimer);
+ listenDebounceTimer = null;
+ }
+ restartPending = false;
+
+ const audioPlayer = document.getElementById('scannerAudioPlayer');
+ if (audioPlayer) {
+ audioPlayer.pause();
+ // Clear MediaSource if using WebSocket
+ if (audioPlayer.msSource) {
+ try {
+ audioPlayer.msSource.endOfStream();
+ } catch (e) {}
+ audioPlayer.msSource = null;
+ audioPlayer.sourceBuffer = null;
+ }
+ audioPlayer.src = '';
+ }
+ audioQueue = [];
+
+ // Stop via WebSocket if connected
+ if (audioWebSocket && audioWebSocket.readyState === WebSocket.OPEN) {
+ sendWebSocketCommand('stop');
+ }
+
+ // Also stop via HTTP (fallback)
+ fetch('/listening/audio/stop', { method: 'POST' }).catch(() => {});
+
+ isDirectListening = false;
+ updateDirectListenUI(false);
+ addScannerLogEntry('Listening stopped');
+}
+
+/**
+ * Update UI for direct listen mode
+ */
+function updateDirectListenUI(isPlaying, freq) {
+ const listenBtn = document.getElementById('radioListenBtn');
+ const statusLabel = document.getElementById('mainScannerModeLabel');
+ const freqDisplay = document.getElementById('mainScannerFreq');
+ const quickStatus = document.getElementById('lpQuickStatus');
+ const quickFreq = document.getElementById('lpQuickFreq');
+
+ if (listenBtn) {
+ if (isPlaying) {
+ listenBtn.innerHTML = '⏹ STOP';
+ listenBtn.style.background = 'var(--accent-red)';
+ listenBtn.style.borderColor = 'var(--accent-red)';
+ } else {
+ listenBtn.innerHTML = '🎧 LISTEN';
+ listenBtn.style.background = 'var(--accent-purple)';
+ listenBtn.style.borderColor = 'var(--accent-purple)';
+ }
+ }
+
+ if (statusLabel) {
+ statusLabel.textContent = isPlaying ? 'LISTENING' : 'STOPPED';
+ statusLabel.style.color = isPlaying ? 'var(--accent-green)' : 'var(--text-muted)';
+ }
+
+ if (freqDisplay && freq) {
+ freqDisplay.textContent = freq.toFixed(3);
+ }
+
+ if (quickStatus) {
+ quickStatus.textContent = isPlaying ? 'LISTENING' : 'IDLE';
+ quickStatus.style.color = isPlaying ? 'var(--accent-green)' : 'var(--accent-cyan)';
+ }
+
+ if (quickFreq && freq) {
+ quickFreq.textContent = freq.toFixed(3) + ' MHz';
+ }
+}
+
+/**
+ * Tune frequency by delta
+ */
+function tuneFreq(delta) {
+ const freqInput = document.getElementById('radioScanStart');
+ if (freqInput) {
+ let newFreq = parseFloat(freqInput.value) + delta;
+ newFreq = Math.max(24, Math.min(1800, newFreq));
+ freqInput.value = newFreq.toFixed(1);
+
+ // Update display
+ const freqDisplay = document.getElementById('mainScannerFreq');
+ if (freqDisplay) {
+ freqDisplay.textContent = newFreq.toFixed(3);
+ }
+
+ // Update tuning dial position (silent to avoid duplicate restart)
+ const mainTuningDial = document.getElementById('mainTuningDial');
+ if (mainTuningDial && mainTuningDial._dial) {
+ mainTuningDial._dial.setValue(newFreq, true);
+ }
+
+ const quickFreq = document.getElementById('lpQuickFreq');
+ if (quickFreq) {
+ quickFreq.textContent = newFreq.toFixed(3) + ' MHz';
+ }
+
+ // If currently listening, restart stream at new frequency
+ if (isDirectListening) {
+ startDirectListen();
+ }
+ }
+}
+
+/**
+ * Quick tune to a preset frequency
+ */
+function quickTune(freq, mod) {
+ // Update frequency inputs
+ const startInput = document.getElementById('radioScanStart');
+ if (startInput) {
+ startInput.value = freq;
+ }
+
+ // Update modulation (don't trigger auto-restart here, we'll handle it below)
+ if (mod) {
+ currentModulation = mod;
+ // Update modulation UI without triggering restart
+ document.querySelectorAll('#modBtnBank .radio-btn').forEach(btn => {
+ btn.classList.toggle('active', btn.dataset.mod === mod);
+ });
+ const badge = document.getElementById('mainScannerMod');
+ if (badge) {
+ const modLabels = { am: 'AM', fm: 'NFM', wfm: 'WFM', usb: 'USB', lsb: 'LSB' };
+ badge.textContent = modLabels[mod] || mod.toUpperCase();
+ }
+ }
+
+ // Update display
+ const freqDisplay = document.getElementById('mainScannerFreq');
+ if (freqDisplay) {
+ freqDisplay.textContent = freq.toFixed(3);
+ }
+
+ // Update tuning dial position (silent to avoid duplicate restart)
+ const mainTuningDial = document.getElementById('mainTuningDial');
+ if (mainTuningDial && mainTuningDial._dial) {
+ mainTuningDial._dial.setValue(freq, true);
+ }
+
+ const quickFreq = document.getElementById('lpQuickFreq');
+ if (quickFreq) {
+ quickFreq.textContent = freq.toFixed(3) + ' MHz';
+ }
+
+ addScannerLogEntry(`Quick tuned to ${freq.toFixed(3)} MHz (${mod.toUpperCase()})`);
+
+ // If currently listening, restart immediately (this is a deliberate preset selection)
+ if (isDirectListening) {
+ startDirectListenImmediate();
+ }
+}
+
+/**
+ * Enhanced setModulation to also update currentModulation
+ * Uses immediate restart if currently listening
+ */
+const originalSetModulation = window.setModulation;
+window.setModulation = function(mod) {
+ console.log('[MODULATION] Setting modulation to:', mod, 'isListening:', isDirectListening);
+ currentModulation = mod;
+
+ // Update modulation button states
+ document.querySelectorAll('#modBtnBank .radio-btn').forEach(btn => {
+ btn.classList.toggle('active', btn.dataset.mod === mod);
+ });
+
+ // Update badge
+ const badge = document.getElementById('mainScannerMod');
+ if (badge) {
+ const modLabels = { am: 'AM', fm: 'NFM', wfm: 'WFM', usb: 'USB', lsb: 'LSB' };
+ badge.textContent = modLabels[mod] || mod.toUpperCase();
+ }
+
+ // Update scanner modulation select if exists
+ const modSelect = document.getElementById('scannerModulation');
+ if (modSelect) {
+ modSelect.value = mod;
+ }
+
+ // Sync with scanner if running
+ updateScannerConfig({ modulation: mod });
+
+ // If currently listening, restart immediately (deliberate modulation change)
+ if (isDirectListening) {
+ console.log('[MODULATION] Restarting audio with new modulation:', mod);
+ startDirectListenImmediate();
+ } else {
+ console.log('[MODULATION] Not listening, just updated UI');
+ }
+};
+
+/**
+ * Update sidebar quick status
+ */
+function updateQuickStatus() {
+ const quickStatus = document.getElementById('lpQuickStatus');
+ const quickFreq = document.getElementById('lpQuickFreq');
+ const quickSignals = document.getElementById('lpQuickSignals');
+
+ if (quickStatus) {
+ if (isScannerRunning) {
+ quickStatus.textContent = isScannerPaused ? 'PAUSED' : 'SCANNING';
+ quickStatus.style.color = isScannerPaused ? 'var(--accent-orange)' : 'var(--accent-green)';
+ } else if (isDirectListening) {
+ quickStatus.textContent = 'LISTENING';
+ quickStatus.style.color = 'var(--accent-green)';
+ } else {
+ quickStatus.textContent = 'IDLE';
+ quickStatus.style.color = 'var(--accent-cyan)';
+ }
+ }
+
+ if (quickSignals) {
+ quickSignals.textContent = scannerSignalCount;
+ }
+}
+
+// ============== SIDEBAR CONTROLS ==============
+
+// Frequency bookmarks stored in localStorage
+let frequencyBookmarks = [];
+
+/**
+ * Load bookmarks from localStorage
+ */
+function loadFrequencyBookmarks() {
+ try {
+ const saved = localStorage.getItem('lpBookmarks');
+ if (saved) {
+ frequencyBookmarks = JSON.parse(saved);
+ renderBookmarks();
+ }
+ } catch (e) {
+ console.warn('Failed to load bookmarks:', e);
+ }
+}
+
+/**
+ * Save bookmarks to localStorage
+ */
+function saveFrequencyBookmarks() {
+ try {
+ localStorage.setItem('lpBookmarks', JSON.stringify(frequencyBookmarks));
+ } catch (e) {
+ console.warn('Failed to save bookmarks:', e);
+ }
+}
+
+/**
+ * Add a frequency bookmark
+ */
+function addFrequencyBookmark() {
+ const input = document.getElementById('bookmarkFreqInput');
+ if (!input) return;
+
+ const freq = parseFloat(input.value);
+ if (isNaN(freq) || freq <= 0) {
+ if (typeof showNotification === 'function') {
+ showNotification('Invalid Frequency', 'Please enter a valid frequency');
+ }
+ return;
+ }
+
+ // Check for duplicates
+ if (frequencyBookmarks.some(b => Math.abs(b.freq - freq) < 0.001)) {
+ if (typeof showNotification === 'function') {
+ showNotification('Duplicate', 'This frequency is already bookmarked');
+ }
+ return;
+ }
+
+ frequencyBookmarks.push({
+ freq: freq,
+ mod: currentModulation || 'am',
+ added: new Date().toISOString()
+ });
+
+ saveFrequencyBookmarks();
+ renderBookmarks();
+ input.value = '';
+
+ if (typeof showNotification === 'function') {
+ showNotification('Bookmark Added', `${freq.toFixed(3)} MHz saved`);
+ }
+}
+
+/**
+ * Remove a bookmark by index
+ */
+function removeBookmark(index) {
+ frequencyBookmarks.splice(index, 1);
+ saveFrequencyBookmarks();
+ renderBookmarks();
+}
+
+/**
+ * Render bookmarks list
+ */
+function renderBookmarks() {
+ const container = document.getElementById('bookmarksList');
+ if (!container) return;
+
+ if (frequencyBookmarks.length === 0) {
+ container.innerHTML = 'No bookmarks saved
';
+ return;
+ }
+
+ container.innerHTML = frequencyBookmarks.map((b, i) => `
+
+ ${b.freq.toFixed(3)} MHz
+ ${b.mod.toUpperCase()}
+ ×
+
+ `).join('');
+}
+
+
+/**
+ * Add a signal to the sidebar recent signals list
+ */
+function addSidebarRecentSignal(freq, mod) {
+ const container = document.getElementById('sidebarRecentSignals');
+ if (!container) return;
+
+ // Clear placeholder if present
+ if (container.innerHTML.includes('No signals yet')) {
+ container.innerHTML = '';
+ }
+
+ const timestamp = new Date().toLocaleTimeString();
+ const signalDiv = document.createElement('div');
+ signalDiv.style.cssText = 'display: flex; justify-content: space-between; align-items: center; padding: 3px 6px; background: rgba(0,255,100,0.1); border-left: 2px solid var(--accent-green); margin-bottom: 2px; border-radius: 2px;';
+ signalDiv.innerHTML = `
+ ${freq.toFixed(3)}
+ ${timestamp}
+ `;
+
+ container.insertBefore(signalDiv, container.firstChild);
+
+ // Keep only last 10 signals
+ while (container.children.length > 10) {
+ container.removeChild(container.lastChild);
+ }
+}
+
+// Load bookmarks on init
+document.addEventListener('DOMContentLoaded', loadFrequencyBookmarks);
+
+// Export functions for HTML onclick handlers
+window.toggleDirectListen = toggleDirectListen;
+window.startDirectListen = startDirectListen;
+window.stopDirectListen = stopDirectListen;
+window.toggleScanner = toggleScanner;
+window.startScanner = startScanner;
+window.stopScanner = stopScanner;
+window.pauseScanner = pauseScanner;
+window.skipSignal = skipSignal;
+// Note: setModulation is already exported with enhancements above
+window.setBand = setBand;
+window.tuneFreq = tuneFreq;
+window.quickTune = quickTune;
+window.addFrequencyBookmark = addFrequencyBookmark;
+window.removeBookmark = removeBookmark;
+window.tuneToFrequency = tuneToFrequency;
+window.clearScannerLog = clearScannerLog;
+window.exportScannerLog = exportScannerLog;
+
diff --git a/templates/index.html b/templates/index.html
index ea6d380..9ba594c 100644
--- a/templates/index.html
+++ b/templates/index.html
@@ -3,7 +3,7 @@
- INTERCEPT // See the Invisible
+ iNTERCEPT // See the Invisible
@@ -17,13 +17,45 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
iNTERCEPT
+
// See the Invisible
+
Signal Intelligence & Counter Surveillance Platform
+
+ ENTER
+ →
+
+
v{{ version }}
+
+
+
+
-
+
⚠️
DISCLAIMER
- INTERCEPT is a signal intelligence tool designed for educational purposes only .
+ iNTERCEPT is a signal intelligence tool designed for educational purposes only .
By using this software, you acknowledge and agree that:
@@ -90,21 +122,27 @@
?
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
- INTERCEPT // See the Invisible v{{ version }}
- Signal Intelligence Platform PAGER
+ iNTERCEPT // See the Invisible v{{ version }}
+ Signal Intelligence & Counter Surveillance Platform PAGER