mirror of
https://github.com/smittix/intercept.git
synced 2026-04-24 06:40:00 -07:00
chore: commit all pending changes
This commit is contained in:
@@ -96,7 +96,7 @@ def parse_multimon_output(line: str) -> dict[str, str] | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def log_message(msg: dict[str, Any]) -> None:
|
def log_message(msg: dict[str, Any]) -> None:
|
||||||
"""Log a message to file if logging is enabled."""
|
"""Log a message to file if logging is enabled."""
|
||||||
if not app_module.logging_enabled:
|
if not app_module.logging_enabled:
|
||||||
return
|
return
|
||||||
@@ -104,25 +104,39 @@ def log_message(msg: dict[str, Any]) -> None:
|
|||||||
with open(app_module.log_file_path, 'a') as f:
|
with open(app_module.log_file_path, 'a') as f:
|
||||||
timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||||
f.write(f"{timestamp} | {msg.get('protocol', 'UNKNOWN')} | {msg.get('address', '')} | {msg.get('message', '')}\n")
|
f.write(f"{timestamp} | {msg.get('protocol', 'UNKNOWN')} | {msg.get('address', '')} | {msg.get('message', '')}\n")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to log message: {e}")
|
logger.error(f"Failed to log message: {e}")
|
||||||
|
|
||||||
|
|
||||||
def audio_relay_thread(
|
def _encode_scope_waveform(samples: tuple[int, ...], window_size: int = 256) -> list[int]:
|
||||||
rtl_stdout,
|
"""Compress recent PCM samples into a signed 8-bit waveform for SSE."""
|
||||||
multimon_stdin,
|
if not samples:
|
||||||
output_queue: queue.Queue,
|
return []
|
||||||
stop_event: threading.Event,
|
|
||||||
) -> None:
|
window = samples[-window_size:] if len(samples) > window_size else samples
|
||||||
"""Relay audio from rtl_fm to multimon-ng while computing signal levels.
|
waveform: list[int] = []
|
||||||
|
for sample in window:
|
||||||
Reads raw 16-bit LE PCM from *rtl_stdout*, writes every chunk straight
|
# Convert int16 PCM to int8 range for lightweight transport.
|
||||||
through to *multimon_stdin*, and every ~100 ms pushes an RMS / peak scope
|
packed = int(round(sample / 256))
|
||||||
event onto *output_queue*.
|
waveform.append(max(-127, min(127, packed)))
|
||||||
"""
|
return waveform
|
||||||
CHUNK = 4096 # bytes – 2048 samples at 16-bit mono
|
|
||||||
INTERVAL = 0.1 # seconds between scope updates
|
|
||||||
last_scope = time.monotonic()
|
def audio_relay_thread(
|
||||||
|
rtl_stdout,
|
||||||
|
multimon_stdin,
|
||||||
|
output_queue: queue.Queue,
|
||||||
|
stop_event: threading.Event,
|
||||||
|
) -> None:
|
||||||
|
"""Relay audio from rtl_fm to multimon-ng while computing signal levels.
|
||||||
|
|
||||||
|
Reads raw 16-bit LE PCM from *rtl_stdout*, writes every chunk straight
|
||||||
|
through to *multimon_stdin*, and every ~100 ms pushes an RMS / peak scope
|
||||||
|
event plus a compact waveform sample onto *output_queue*.
|
||||||
|
"""
|
||||||
|
CHUNK = 4096 # bytes – 2048 samples at 16-bit mono
|
||||||
|
INTERVAL = 0.1 # seconds between scope updates
|
||||||
|
last_scope = time.monotonic()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
while not stop_event.is_set():
|
while not stop_event.is_set():
|
||||||
@@ -146,15 +160,16 @@ def audio_relay_thread(
|
|||||||
if n_samples == 0:
|
if n_samples == 0:
|
||||||
continue
|
continue
|
||||||
samples = struct.unpack(f'<{n_samples}h', data[:n_samples * 2])
|
samples = struct.unpack(f'<{n_samples}h', data[:n_samples * 2])
|
||||||
peak = max(abs(s) for s in samples)
|
peak = max(abs(s) for s in samples)
|
||||||
rms = int(math.sqrt(sum(s * s for s in samples) / n_samples))
|
rms = int(math.sqrt(sum(s * s for s in samples) / n_samples))
|
||||||
output_queue.put_nowait({
|
output_queue.put_nowait({
|
||||||
'type': 'scope',
|
'type': 'scope',
|
||||||
'rms': rms,
|
'rms': rms,
|
||||||
'peak': peak,
|
'peak': peak,
|
||||||
})
|
'waveform': _encode_scope_waveform(samples),
|
||||||
except (struct.error, ValueError, queue.Full):
|
})
|
||||||
pass
|
except (struct.error, ValueError, queue.Full):
|
||||||
|
pass
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f"Audio relay error: {e}")
|
logger.debug(f"Audio relay error: {e}")
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
"""RTL_433 sensor monitoring routes."""
|
"""RTL_433 sensor monitoring routes."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import queue
|
import math
|
||||||
import subprocess
|
import queue
|
||||||
import threading
|
import subprocess
|
||||||
import time
|
import threading
|
||||||
from datetime import datetime
|
import time
|
||||||
from typing import Generator
|
from datetime import datetime
|
||||||
|
from typing import Any, Generator
|
||||||
|
|
||||||
from flask import Blueprint, jsonify, request, Response
|
from flask import Blueprint, jsonify, request, Response
|
||||||
|
|
||||||
@@ -28,12 +29,42 @@ sensor_bp = Blueprint('sensor', __name__)
|
|||||||
# Track which device is being used
|
# Track which device is being used
|
||||||
sensor_active_device: int | None = None
|
sensor_active_device: int | None = None
|
||||||
|
|
||||||
# RSSI history per device (model_id -> list of (timestamp, rssi))
|
# RSSI history per device (model_id -> list of (timestamp, rssi))
|
||||||
sensor_rssi_history: dict[str, list[tuple[float, float]]] = {}
|
sensor_rssi_history: dict[str, list[tuple[float, float]]] = {}
|
||||||
_MAX_RSSI_HISTORY = 60
|
_MAX_RSSI_HISTORY = 60
|
||||||
|
|
||||||
|
|
||||||
def stream_sensor_output(process: subprocess.Popen[bytes]) -> None:
|
def _build_scope_waveform(rssi: float, snr: float, noise: float, points: int = 256) -> list[int]:
|
||||||
|
"""Synthesize a compact waveform from rtl_433 level metrics."""
|
||||||
|
points = max(32, min(points, 512))
|
||||||
|
|
||||||
|
# rssi is usually negative; stronger signals are closer to 0 dBm.
|
||||||
|
rssi_norm = min(max(abs(rssi) / 40.0, 0.0), 1.0)
|
||||||
|
snr_norm = min(max((snr + 5.0) / 35.0, 0.0), 1.0)
|
||||||
|
noise_norm = min(max(abs(noise) / 40.0, 0.0), 1.0)
|
||||||
|
|
||||||
|
amplitude = max(0.06, min(1.0, (0.6 * rssi_norm + 0.4 * snr_norm) - (0.22 * noise_norm)))
|
||||||
|
cycles = 3.0 + (snr_norm * 8.0)
|
||||||
|
harmonic = 0.25 + (0.35 * snr_norm)
|
||||||
|
hiss = 0.08 + (0.18 * noise_norm)
|
||||||
|
phase = (time.monotonic() * (1.4 + (snr_norm * 2.2))) % (2.0 * math.pi)
|
||||||
|
|
||||||
|
waveform: list[int] = []
|
||||||
|
for i in range(points):
|
||||||
|
t = i / (points - 1)
|
||||||
|
base = math.sin((2.0 * math.pi * cycles * t) + phase)
|
||||||
|
overtone = math.sin((2.0 * math.pi * (cycles * 2.4) * t) + (phase * 0.7))
|
||||||
|
noise_wobble = math.sin((2.0 * math.pi * (cycles * 7.0) * t) + (phase * 2.1))
|
||||||
|
|
||||||
|
sample = amplitude * (base + (harmonic * overtone) + (hiss * noise_wobble))
|
||||||
|
sample /= (1.0 + harmonic + hiss)
|
||||||
|
packed = int(round(max(-1.0, min(1.0, sample)) * 127.0))
|
||||||
|
waveform.append(max(-127, min(127, packed)))
|
||||||
|
|
||||||
|
return waveform
|
||||||
|
|
||||||
|
|
||||||
|
def stream_sensor_output(process: subprocess.Popen[bytes]) -> None:
|
||||||
"""Stream rtl_433 JSON output to queue."""
|
"""Stream rtl_433 JSON output to queue."""
|
||||||
try:
|
try:
|
||||||
app_module.sensor_queue.put({'type': 'status', 'text': 'started'})
|
app_module.sensor_queue.put({'type': 'status', 'text': 'started'})
|
||||||
@@ -64,16 +95,24 @@ def stream_sensor_output(process: subprocess.Popen[bytes]) -> None:
|
|||||||
rssi = data.get('rssi')
|
rssi = data.get('rssi')
|
||||||
snr = data.get('snr')
|
snr = data.get('snr')
|
||||||
noise = data.get('noise')
|
noise = data.get('noise')
|
||||||
if rssi is not None or snr is not None:
|
if rssi is not None or snr is not None:
|
||||||
try:
|
try:
|
||||||
app_module.sensor_queue.put_nowait({
|
rssi_value = float(rssi) if rssi is not None else 0.0
|
||||||
'type': 'scope',
|
snr_value = float(snr) if snr is not None else 0.0
|
||||||
'rssi': rssi if rssi is not None else 0,
|
noise_value = float(noise) if noise is not None else 0.0
|
||||||
'snr': snr if snr is not None else 0,
|
app_module.sensor_queue.put_nowait({
|
||||||
'noise': noise if noise is not None else 0,
|
'type': 'scope',
|
||||||
})
|
'rssi': rssi_value,
|
||||||
except queue.Full:
|
'snr': snr_value,
|
||||||
pass
|
'noise': noise_value,
|
||||||
|
'waveform': _build_scope_waveform(
|
||||||
|
rssi=rssi_value,
|
||||||
|
snr=snr_value,
|
||||||
|
noise=noise_value,
|
||||||
|
),
|
||||||
|
})
|
||||||
|
except (TypeError, ValueError, queue.Full):
|
||||||
|
pass
|
||||||
|
|
||||||
# Log if enabled
|
# Log if enabled
|
||||||
if app_module.logging_enabled:
|
if app_module.logging_enabled:
|
||||||
|
|||||||
@@ -898,66 +898,76 @@ body {
|
|||||||
inset: 0;
|
inset: 0;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
z-index: 650;
|
z-index: 1200;
|
||||||
--target-x: 50%;
|
--crosshair-x-start: 100%;
|
||||||
--target-y: 50%;
|
--crosshair-y-start: 100%;
|
||||||
|
--crosshair-x-end: 50%;
|
||||||
|
--crosshair-y-end: 50%;
|
||||||
|
--crosshair-duration: 1500ms;
|
||||||
}
|
}
|
||||||
|
|
||||||
.map-crosshair-line {
|
.map-crosshair-line {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
background: var(--accent-cyan);
|
background: var(--accent-cyan);
|
||||||
box-shadow: 0 0 10px var(--accent-cyan);
|
box-shadow: none;
|
||||||
|
will-change: transform, opacity;
|
||||||
}
|
}
|
||||||
|
|
||||||
.map-crosshair-vertical {
|
.map-crosshair-vertical {
|
||||||
top: 0;
|
top: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
width: 2px;
|
width: 1px;
|
||||||
right: 0;
|
left: 0;
|
||||||
transform: translateX(0);
|
transform: translateX(var(--crosshair-x-start));
|
||||||
}
|
}
|
||||||
|
|
||||||
.map-crosshair-horizontal {
|
.map-crosshair-horizontal {
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
height: 2px;
|
height: 1px;
|
||||||
bottom: 0;
|
top: 0;
|
||||||
transform: translateY(0);
|
transform: translateY(var(--crosshair-y-start));
|
||||||
}
|
}
|
||||||
|
|
||||||
.map-crosshair-overlay.active .map-crosshair-vertical {
|
.map-crosshair-overlay.active .map-crosshair-vertical {
|
||||||
animation: mapCrosshairSweepX 620ms cubic-bezier(0.2, 0.85, 0.28, 1) forwards;
|
animation: mapCrosshairSweepX var(--crosshair-duration) cubic-bezier(0.2, 0.85, 0.28, 1) forwards;
|
||||||
}
|
}
|
||||||
|
|
||||||
.map-crosshair-overlay.active .map-crosshair-horizontal {
|
.map-crosshair-overlay.active .map-crosshair-horizontal {
|
||||||
animation: mapCrosshairSweepY 620ms cubic-bezier(0.2, 0.85, 0.28, 1) forwards;
|
animation: mapCrosshairSweepY var(--crosshair-duration) cubic-bezier(0.2, 0.85, 0.28, 1) forwards;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes mapCrosshairSweepX {
|
@keyframes mapCrosshairSweepX {
|
||||||
0% {
|
0% {
|
||||||
transform: translateX(0);
|
transform: translateX(var(--crosshair-x-start));
|
||||||
opacity: 0.95;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
75% {
|
12% {
|
||||||
opacity: 0.95;
|
opacity: 1;
|
||||||
|
}
|
||||||
|
85% {
|
||||||
|
opacity: 1;
|
||||||
}
|
}
|
||||||
100% {
|
100% {
|
||||||
transform: translateX(calc(var(--target-x) - 100%));
|
transform: translateX(var(--crosshair-x-end));
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes mapCrosshairSweepY {
|
@keyframes mapCrosshairSweepY {
|
||||||
0% {
|
0% {
|
||||||
transform: translateY(0);
|
transform: translateY(var(--crosshair-y-start));
|
||||||
opacity: 0.95;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
75% {
|
12% {
|
||||||
opacity: 0.95;
|
opacity: 1;
|
||||||
|
}
|
||||||
|
85% {
|
||||||
|
opacity: 1;
|
||||||
}
|
}
|
||||||
100% {
|
100% {
|
||||||
transform: translateY(calc(var(--target-y) - 100%));
|
transform: translateY(var(--crosshair-y-end));
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -965,7 +975,7 @@ body {
|
|||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.map-crosshair-overlay.active .map-crosshair-vertical,
|
.map-crosshair-overlay.active .map-crosshair-vertical,
|
||||||
.map-crosshair-overlay.active .map-crosshair-horizontal {
|
.map-crosshair-overlay.active .map-crosshair-horizontal {
|
||||||
animation-duration: 120ms;
|
animation-duration: 220ms;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -479,10 +479,6 @@
|
|||||||
filter: sepia(0.35) hue-rotate(185deg) saturate(1.75) brightness(1.06) contrast(1.05);
|
filter: sepia(0.35) hue-rotate(185deg) saturate(1.75) brightness(1.06) contrast(1.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tile-layer-flir {
|
|
||||||
filter: grayscale(1) sepia(1) hue-rotate(-18deg) saturate(4.85) brightness(0.96) contrast(1.34);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Global Leaflet map theme: cyber overlay */
|
/* Global Leaflet map theme: cyber overlay */
|
||||||
.leaflet-container.map-theme-cyber {
|
.leaflet-container.map-theme-cyber {
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -531,55 +527,6 @@ html.map-cyber-enabled .leaflet-container::after {
|
|||||||
background-size: 52px 52px, 52px 52px;
|
background-size: 52px 52px, 52px 52px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Global Leaflet map theme: FLIR thermal overlay */
|
|
||||||
.leaflet-container.map-theme-flir {
|
|
||||||
position: relative;
|
|
||||||
background: #090602;
|
|
||||||
isolation: isolate;
|
|
||||||
}
|
|
||||||
|
|
||||||
.leaflet-container.map-theme-flir .leaflet-tile-pane {
|
|
||||||
filter: grayscale(1) sepia(1) hue-rotate(-18deg) saturate(4.85) brightness(0.96) contrast(1.34);
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Hard global fallback: enforce FLIR tint on all Leaflet tile images */
|
|
||||||
html.map-flir-enabled .leaflet-container .leaflet-tile {
|
|
||||||
filter: grayscale(1) sepia(1) hue-rotate(-18deg) saturate(4.85) brightness(0.96) contrast(1.34) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Hard global fallback: thermal glow + scanline/grid overlay */
|
|
||||||
html.map-flir-enabled .leaflet-container {
|
|
||||||
position: relative;
|
|
||||||
isolation: isolate;
|
|
||||||
}
|
|
||||||
|
|
||||||
html.map-flir-enabled .leaflet-container::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
z-index: 620;
|
|
||||||
background:
|
|
||||||
radial-gradient(115% 90% at 50% 40%, rgba(255, 132, 28, 0.22), rgba(255, 132, 28, 0) 63%),
|
|
||||||
linear-gradient(180deg, rgba(14, 229, 255, 0.08) 0%, rgba(255, 96, 18, 0.15) 58%, rgba(255, 233, 128, 0.11) 100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
html.map-flir-enabled .leaflet-container::after {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
z-index: 621;
|
|
||||||
opacity: 0.32;
|
|
||||||
mix-blend-mode: screen;
|
|
||||||
background-image:
|
|
||||||
repeating-linear-gradient(0deg, rgba(255, 188, 92, 0.08) 0 1px, transparent 1px 3px),
|
|
||||||
linear-gradient(90deg, rgba(255, 141, 66, 0.12) 1px, transparent 1px),
|
|
||||||
linear-gradient(rgba(0, 240, 255, 0.07) 1px, transparent 1px);
|
|
||||||
background-size: 100% 3px, 68px 68px, 68px 68px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Responsive */
|
/* Responsive */
|
||||||
@media (max-width: 960px) {
|
@media (max-width: 960px) {
|
||||||
.settings-tabs {
|
.settings-tabs {
|
||||||
|
|||||||
@@ -33,15 +33,6 @@ const Settings = {
|
|||||||
mapTheme: 'cyber',
|
mapTheme: 'cyber',
|
||||||
options: {}
|
options: {}
|
||||||
},
|
},
|
||||||
cartodb_dark_flir: {
|
|
||||||
url: 'https://cartodb-basemaps-{s}.global.ssl.fastly.net/dark_all/{z}/{x}/{y}.png',
|
|
||||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OSM</a> © <a href="https://carto.com/">CARTO</a>',
|
|
||||||
subdomains: 'abcd',
|
|
||||||
mapTheme: 'flir',
|
|
||||||
options: {
|
|
||||||
className: 'tile-layer-flir'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
cartodb_light: {
|
cartodb_light: {
|
||||||
url: 'https://cartodb-basemaps-{s}.global.ssl.fastly.net/light_all/{z}/{x}/{y}.png',
|
url: 'https://cartodb-basemaps-{s}.global.ssl.fastly.net/light_all/{z}/{x}/{y}.png',
|
||||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OSM</a> © <a href="https://carto.com/">CARTO</a>',
|
attribution: '© <a href="https://www.openstreetmap.org/copyright">OSM</a> © <a href="https://carto.com/">CARTO</a>',
|
||||||
@@ -116,7 +107,6 @@ const Settings = {
|
|||||||
const resolvedConfig = config || this.getTileConfig();
|
const resolvedConfig = config || this.getTileConfig();
|
||||||
const themeClass = this._getMapThemeClass(resolvedConfig);
|
const themeClass = this._getMapThemeClass(resolvedConfig);
|
||||||
document.documentElement.classList.toggle('map-cyber-enabled', themeClass === 'map-theme-cyber');
|
document.documentElement.classList.toggle('map-cyber-enabled', themeClass === 'map-theme-cyber');
|
||||||
document.documentElement.classList.toggle('map-flir-enabled', themeClass === 'map-theme-flir');
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -351,7 +341,6 @@ const Settings = {
|
|||||||
_getMapThemeClass(config) {
|
_getMapThemeClass(config) {
|
||||||
if (!config || !config.mapTheme) return null;
|
if (!config || !config.mapTheme) return null;
|
||||||
if (config.mapTheme === 'cyber') return 'map-theme-cyber';
|
if (config.mapTheme === 'cyber') return 'map-theme-cyber';
|
||||||
if (config.mapTheme === 'flir') return 'map-theme-flir';
|
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -375,7 +364,7 @@ const Settings = {
|
|||||||
container.style.background = '';
|
container.style.background = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
container.classList.remove('map-theme-cyber', 'map-theme-flir');
|
container.classList.remove('map-theme-cyber');
|
||||||
|
|
||||||
const resolvedConfig = config || this.getTileConfig();
|
const resolvedConfig = config || this.getTileConfig();
|
||||||
const themeClass = this._getMapThemeClass(resolvedConfig);
|
const themeClass = this._getMapThemeClass(resolvedConfig);
|
||||||
@@ -392,15 +381,6 @@ const Settings = {
|
|||||||
tilePane.style.opacity = '1';
|
tilePane.style.opacity = '1';
|
||||||
tilePane.style.willChange = 'filter';
|
tilePane.style.willChange = 'filter';
|
||||||
}
|
}
|
||||||
} else if (themeClass === 'map-theme-flir') {
|
|
||||||
if (container.style) {
|
|
||||||
container.style.background = '#090602';
|
|
||||||
}
|
|
||||||
if (tilePane && tilePane.style) {
|
|
||||||
tilePane.style.filter = 'grayscale(1) sepia(1) hue-rotate(-18deg) saturate(4.85) brightness(0.96) contrast(1.34)';
|
|
||||||
tilePane.style.opacity = '1';
|
|
||||||
tilePane.style.willChange = 'filter';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Map overlays are rendered via CSS pseudo elements on
|
// Map overlays are rendered via CSS pseudo elements on
|
||||||
|
|||||||
@@ -15,13 +15,21 @@ const SSTVGeneral = (function() {
|
|||||||
let sstvGeneralScopeCtx = null;
|
let sstvGeneralScopeCtx = null;
|
||||||
let sstvGeneralScopeAnim = null;
|
let sstvGeneralScopeAnim = null;
|
||||||
let sstvGeneralScopeHistory = [];
|
let sstvGeneralScopeHistory = [];
|
||||||
|
let sstvGeneralScopeWaveBuffer = [];
|
||||||
|
let sstvGeneralScopeDisplayWave = [];
|
||||||
const SSTV_GENERAL_SCOPE_LEN = 200;
|
const SSTV_GENERAL_SCOPE_LEN = 200;
|
||||||
|
const SSTV_GENERAL_SCOPE_WAVE_BUFFER_LEN = 2048;
|
||||||
|
const SSTV_GENERAL_SCOPE_WAVE_INPUT_SMOOTH_ALPHA = 0.55;
|
||||||
|
const SSTV_GENERAL_SCOPE_WAVE_DISPLAY_SMOOTH_ALPHA = 0.22;
|
||||||
|
const SSTV_GENERAL_SCOPE_WAVE_IDLE_DECAY = 0.96;
|
||||||
let sstvGeneralScopeRms = 0;
|
let sstvGeneralScopeRms = 0;
|
||||||
let sstvGeneralScopePeak = 0;
|
let sstvGeneralScopePeak = 0;
|
||||||
let sstvGeneralScopeTargetRms = 0;
|
let sstvGeneralScopeTargetRms = 0;
|
||||||
let sstvGeneralScopeTargetPeak = 0;
|
let sstvGeneralScopeTargetPeak = 0;
|
||||||
let sstvGeneralScopeMsgBurst = 0;
|
let sstvGeneralScopeMsgBurst = 0;
|
||||||
let sstvGeneralScopeTone = null;
|
let sstvGeneralScopeTone = null;
|
||||||
|
let sstvGeneralScopeLastWaveAt = 0;
|
||||||
|
let sstvGeneralScopeLastInputSample = 0;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize the SSTV General mode
|
* Initialize the SSTV General mode
|
||||||
@@ -205,20 +213,64 @@ const SSTVGeneral = (function() {
|
|||||||
/**
|
/**
|
||||||
* Initialize signal scope canvas
|
* Initialize signal scope canvas
|
||||||
*/
|
*/
|
||||||
|
function resizeSstvGeneralScopeCanvas(canvas) {
|
||||||
|
if (!canvas) return;
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
const width = Math.max(1, Math.floor(rect.width * dpr));
|
||||||
|
const height = Math.max(1, Math.floor(rect.height * dpr));
|
||||||
|
if (canvas.width !== width || canvas.height !== height) {
|
||||||
|
canvas.width = width;
|
||||||
|
canvas.height = height;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applySstvGeneralScopeData(scopeData) {
|
||||||
|
if (!scopeData || typeof scopeData !== 'object') return;
|
||||||
|
|
||||||
|
sstvGeneralScopeTargetRms = Number(scopeData.rms) || 0;
|
||||||
|
sstvGeneralScopeTargetPeak = Number(scopeData.peak) || 0;
|
||||||
|
if (scopeData.tone !== undefined) {
|
||||||
|
sstvGeneralScopeTone = scopeData.tone;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(scopeData.waveform) && scopeData.waveform.length) {
|
||||||
|
for (const packedSample of scopeData.waveform) {
|
||||||
|
const sample = Number(packedSample);
|
||||||
|
if (!Number.isFinite(sample)) continue;
|
||||||
|
const normalized = Math.max(-127, Math.min(127, sample)) / 127;
|
||||||
|
sstvGeneralScopeLastInputSample += (normalized - sstvGeneralScopeLastInputSample) * SSTV_GENERAL_SCOPE_WAVE_INPUT_SMOOTH_ALPHA;
|
||||||
|
sstvGeneralScopeWaveBuffer.push(sstvGeneralScopeLastInputSample);
|
||||||
|
}
|
||||||
|
if (sstvGeneralScopeWaveBuffer.length > SSTV_GENERAL_SCOPE_WAVE_BUFFER_LEN) {
|
||||||
|
sstvGeneralScopeWaveBuffer.splice(0, sstvGeneralScopeWaveBuffer.length - SSTV_GENERAL_SCOPE_WAVE_BUFFER_LEN);
|
||||||
|
}
|
||||||
|
sstvGeneralScopeLastWaveAt = performance.now();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function initSstvGeneralScope() {
|
function initSstvGeneralScope() {
|
||||||
const canvas = document.getElementById('sstvGeneralScopeCanvas');
|
const canvas = document.getElementById('sstvGeneralScopeCanvas');
|
||||||
if (!canvas) return;
|
if (!canvas) return;
|
||||||
const rect = canvas.getBoundingClientRect();
|
|
||||||
canvas.width = rect.width * (window.devicePixelRatio || 1);
|
if (sstvGeneralScopeAnim) {
|
||||||
canvas.height = rect.height * (window.devicePixelRatio || 1);
|
cancelAnimationFrame(sstvGeneralScopeAnim);
|
||||||
|
sstvGeneralScopeAnim = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
resizeSstvGeneralScopeCanvas(canvas);
|
||||||
sstvGeneralScopeCtx = canvas.getContext('2d');
|
sstvGeneralScopeCtx = canvas.getContext('2d');
|
||||||
sstvGeneralScopeHistory = new Array(SSTV_GENERAL_SCOPE_LEN).fill(0);
|
sstvGeneralScopeHistory = new Array(SSTV_GENERAL_SCOPE_LEN).fill(0);
|
||||||
|
sstvGeneralScopeWaveBuffer = [];
|
||||||
|
sstvGeneralScopeDisplayWave = [];
|
||||||
sstvGeneralScopeRms = 0;
|
sstvGeneralScopeRms = 0;
|
||||||
sstvGeneralScopePeak = 0;
|
sstvGeneralScopePeak = 0;
|
||||||
sstvGeneralScopeTargetRms = 0;
|
sstvGeneralScopeTargetRms = 0;
|
||||||
sstvGeneralScopeTargetPeak = 0;
|
sstvGeneralScopeTargetPeak = 0;
|
||||||
sstvGeneralScopeMsgBurst = 0;
|
sstvGeneralScopeMsgBurst = 0;
|
||||||
sstvGeneralScopeTone = null;
|
sstvGeneralScopeTone = null;
|
||||||
|
sstvGeneralScopeLastWaveAt = 0;
|
||||||
|
sstvGeneralScopeLastInputSample = 0;
|
||||||
drawSstvGeneralScope();
|
drawSstvGeneralScope();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,12 +280,14 @@ const SSTVGeneral = (function() {
|
|||||||
function drawSstvGeneralScope() {
|
function drawSstvGeneralScope() {
|
||||||
const ctx = sstvGeneralScopeCtx;
|
const ctx = sstvGeneralScopeCtx;
|
||||||
if (!ctx) return;
|
if (!ctx) return;
|
||||||
|
|
||||||
|
resizeSstvGeneralScopeCanvas(ctx.canvas);
|
||||||
const W = ctx.canvas.width;
|
const W = ctx.canvas.width;
|
||||||
const H = ctx.canvas.height;
|
const H = ctx.canvas.height;
|
||||||
const midY = H / 2;
|
const midY = H / 2;
|
||||||
|
|
||||||
// Phosphor persistence
|
// Phosphor persistence
|
||||||
ctx.fillStyle = 'rgba(5, 5, 16, 0.3)';
|
ctx.fillStyle = 'rgba(5, 5, 16, 0.26)';
|
||||||
ctx.fillRect(0, 0, W, H);
|
ctx.fillRect(0, 0, W, H);
|
||||||
|
|
||||||
// Smooth towards target
|
// Smooth towards target
|
||||||
@@ -256,32 +310,84 @@ const SSTVGeneral = (function() {
|
|||||||
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke();
|
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Waveform
|
// Envelope
|
||||||
const stepX = W / (SSTV_GENERAL_SCOPE_LEN - 1);
|
const envStepX = W / (SSTV_GENERAL_SCOPE_LEN - 1);
|
||||||
ctx.strokeStyle = '#c080ff';
|
ctx.strokeStyle = 'rgba(168, 110, 255, 0.45)';
|
||||||
ctx.lineWidth = 1.5;
|
ctx.lineWidth = 1;
|
||||||
ctx.shadowColor = '#c080ff';
|
|
||||||
ctx.shadowBlur = 4;
|
|
||||||
|
|
||||||
// Upper half
|
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
for (let i = 0; i < sstvGeneralScopeHistory.length; i++) {
|
for (let i = 0; i < sstvGeneralScopeHistory.length; i++) {
|
||||||
const x = i * stepX;
|
const x = i * envStepX;
|
||||||
const amp = sstvGeneralScopeHistory[i] * midY * 0.9;
|
const amp = sstvGeneralScopeHistory[i] * midY * 0.85;
|
||||||
const y = midY - amp;
|
const y = midY - amp;
|
||||||
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
|
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
|
||||||
}
|
}
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
|
|
||||||
// Lower half (mirror)
|
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
for (let i = 0; i < sstvGeneralScopeHistory.length; i++) {
|
for (let i = 0; i < sstvGeneralScopeHistory.length; i++) {
|
||||||
const x = i * stepX;
|
const x = i * envStepX;
|
||||||
const amp = sstvGeneralScopeHistory[i] * midY * 0.9;
|
const amp = sstvGeneralScopeHistory[i] * midY * 0.85;
|
||||||
const y = midY + amp;
|
const y = midY + amp;
|
||||||
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
|
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
|
||||||
}
|
}
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Actual waveform trace
|
||||||
|
const waveformPointCount = Math.min(Math.max(120, Math.floor(W / 3.2)), 420);
|
||||||
|
if (sstvGeneralScopeWaveBuffer.length > 1) {
|
||||||
|
const waveIsFresh = (performance.now() - sstvGeneralScopeLastWaveAt) < 1000;
|
||||||
|
const sourceLen = sstvGeneralScopeWaveBuffer.length;
|
||||||
|
const sourceWindow = Math.min(sourceLen, 1536);
|
||||||
|
const sourceStart = sourceLen - sourceWindow;
|
||||||
|
|
||||||
|
if (sstvGeneralScopeDisplayWave.length !== waveformPointCount) {
|
||||||
|
sstvGeneralScopeDisplayWave = new Array(waveformPointCount).fill(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < waveformPointCount; i++) {
|
||||||
|
const a = sourceStart + Math.floor((i / waveformPointCount) * sourceWindow);
|
||||||
|
const b = sourceStart + Math.floor(((i + 1) / waveformPointCount) * sourceWindow);
|
||||||
|
const start = Math.max(sourceStart, Math.min(sourceLen - 1, a));
|
||||||
|
const end = Math.max(start + 1, Math.min(sourceLen, b));
|
||||||
|
|
||||||
|
let sum = 0;
|
||||||
|
let count = 0;
|
||||||
|
for (let j = start; j < end; j++) {
|
||||||
|
sum += sstvGeneralScopeWaveBuffer[j];
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
const targetSample = count > 0 ? (sum / count) : 0;
|
||||||
|
sstvGeneralScopeDisplayWave[i] += (targetSample - sstvGeneralScopeDisplayWave[i]) * SSTV_GENERAL_SCOPE_WAVE_DISPLAY_SMOOTH_ALPHA;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.strokeStyle = waveIsFresh ? '#c080ff' : 'rgba(192, 128, 255, 0.45)';
|
||||||
|
ctx.lineWidth = 1.7;
|
||||||
|
ctx.shadowColor = '#c080ff';
|
||||||
|
ctx.shadowBlur = waveIsFresh ? 6 : 2;
|
||||||
|
|
||||||
|
const stepX = waveformPointCount > 1 ? (W / (waveformPointCount - 1)) : W;
|
||||||
|
ctx.beginPath();
|
||||||
|
const firstY = midY - (sstvGeneralScopeDisplayWave[0] * midY * 0.9);
|
||||||
|
ctx.moveTo(0, firstY);
|
||||||
|
for (let i = 1; i < waveformPointCount - 1; i++) {
|
||||||
|
const x = i * stepX;
|
||||||
|
const y = midY - (sstvGeneralScopeDisplayWave[i] * midY * 0.9);
|
||||||
|
const nx = (i + 1) * stepX;
|
||||||
|
const ny = midY - (sstvGeneralScopeDisplayWave[i + 1] * midY * 0.9);
|
||||||
|
const cx = (x + nx) / 2;
|
||||||
|
const cy = (y + ny) / 2;
|
||||||
|
ctx.quadraticCurveTo(x, y, cx, cy);
|
||||||
|
}
|
||||||
|
const lastX = (waveformPointCount - 1) * stepX;
|
||||||
|
const lastY = midY - (sstvGeneralScopeDisplayWave[waveformPointCount - 1] * midY * 0.9);
|
||||||
|
ctx.lineTo(lastX, lastY);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
if (!waveIsFresh) {
|
||||||
|
for (let i = 0; i < sstvGeneralScopeDisplayWave.length; i++) {
|
||||||
|
sstvGeneralScopeDisplayWave[i] *= SSTV_GENERAL_SCOPE_WAVE_IDLE_DECAY;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
ctx.shadowBlur = 0;
|
ctx.shadowBlur = 0;
|
||||||
|
|
||||||
// Peak indicator
|
// Peak indicator
|
||||||
@@ -317,8 +423,17 @@ const SSTVGeneral = (function() {
|
|||||||
else { toneLabel.textContent = 'QUIET'; toneLabel.style.color = '#444'; }
|
else { toneLabel.textContent = 'QUIET'; toneLabel.style.color = '#444'; }
|
||||||
}
|
}
|
||||||
if (statusLabel) {
|
if (statusLabel) {
|
||||||
if (sstvGeneralScopeRms > 500) { statusLabel.textContent = 'SIGNAL'; statusLabel.style.color = '#0f0'; }
|
const waveIsFresh = (performance.now() - sstvGeneralScopeLastWaveAt) < 1000;
|
||||||
else { statusLabel.textContent = 'MONITORING'; statusLabel.style.color = '#555'; }
|
if (sstvGeneralScopeRms > 900 && waveIsFresh) {
|
||||||
|
statusLabel.textContent = 'DEMODULATING';
|
||||||
|
statusLabel.style.color = '#c080ff';
|
||||||
|
} else if (sstvGeneralScopeRms > 500) {
|
||||||
|
statusLabel.textContent = 'CARRIER';
|
||||||
|
statusLabel.style.color = '#e0b8ff';
|
||||||
|
} else {
|
||||||
|
statusLabel.textContent = 'QUIET';
|
||||||
|
statusLabel.style.color = '#555';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sstvGeneralScopeAnim = requestAnimationFrame(drawSstvGeneralScope);
|
sstvGeneralScopeAnim = requestAnimationFrame(drawSstvGeneralScope);
|
||||||
@@ -330,6 +445,11 @@ const SSTVGeneral = (function() {
|
|||||||
function stopSstvGeneralScope() {
|
function stopSstvGeneralScope() {
|
||||||
if (sstvGeneralScopeAnim) { cancelAnimationFrame(sstvGeneralScopeAnim); sstvGeneralScopeAnim = null; }
|
if (sstvGeneralScopeAnim) { cancelAnimationFrame(sstvGeneralScopeAnim); sstvGeneralScopeAnim = null; }
|
||||||
sstvGeneralScopeCtx = null;
|
sstvGeneralScopeCtx = null;
|
||||||
|
sstvGeneralScopeWaveBuffer = [];
|
||||||
|
sstvGeneralScopeDisplayWave = [];
|
||||||
|
sstvGeneralScopeHistory = [];
|
||||||
|
sstvGeneralScopeLastWaveAt = 0;
|
||||||
|
sstvGeneralScopeLastInputSample = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -353,9 +473,7 @@ const SSTVGeneral = (function() {
|
|||||||
if (data.type === 'sstv_progress') {
|
if (data.type === 'sstv_progress') {
|
||||||
handleProgress(data);
|
handleProgress(data);
|
||||||
} else if (data.type === 'sstv_scope') {
|
} else if (data.type === 'sstv_scope') {
|
||||||
sstvGeneralScopeTargetRms = data.rms;
|
applySstvGeneralScopeData(data);
|
||||||
sstvGeneralScopeTargetPeak = data.peak;
|
|
||||||
if (data.tone !== undefined) sstvGeneralScopeTone = data.tone;
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to parse SSE message:', err);
|
console.error('Failed to parse SSE message:', err);
|
||||||
|
|||||||
95
static/sw.js
95
static/sw.js
@@ -17,21 +17,51 @@ const STATIC_PREFIXES = [
|
|||||||
'/static/fonts/',
|
'/static/fonts/',
|
||||||
];
|
];
|
||||||
|
|
||||||
const CACHE_EXACT = ['/manifest.json'];
|
const CACHE_EXACT = ['/manifest.json'];
|
||||||
|
|
||||||
function isNetworkOnly(req) {
|
function isHttpRequest(req) {
|
||||||
if (req.method !== 'GET') return true;
|
const url = new URL(req.url);
|
||||||
const accept = req.headers.get('Accept') || '';
|
return url.protocol === 'http:' || url.protocol === 'https:';
|
||||||
if (accept.includes('text/event-stream')) return true;
|
}
|
||||||
const url = new URL(req.url);
|
|
||||||
|
function isNetworkOnly(req) {
|
||||||
|
if (req.method !== 'GET') return true;
|
||||||
|
const accept = req.headers.get('Accept') || '';
|
||||||
|
if (accept.includes('text/event-stream')) return true;
|
||||||
|
const url = new URL(req.url);
|
||||||
return NETWORK_ONLY_PREFIXES.some(p => url.pathname.startsWith(p));
|
return NETWORK_ONLY_PREFIXES.some(p => url.pathname.startsWith(p));
|
||||||
}
|
}
|
||||||
|
|
||||||
function isStaticAsset(req) {
|
function isStaticAsset(req) {
|
||||||
const url = new URL(req.url);
|
const url = new URL(req.url);
|
||||||
if (CACHE_EXACT.includes(url.pathname)) return true;
|
if (CACHE_EXACT.includes(url.pathname)) return true;
|
||||||
return STATIC_PREFIXES.some(p => url.pathname.startsWith(p));
|
return STATIC_PREFIXES.some(p => url.pathname.startsWith(p));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function fallbackResponse(req, status = 503) {
|
||||||
|
const accept = req.headers.get('Accept') || '';
|
||||||
|
if (accept.includes('application/json')) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ status: 'error', message: 'Network unavailable' }),
|
||||||
|
{
|
||||||
|
status,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (accept.includes('text/event-stream')) {
|
||||||
|
return new Response('', {
|
||||||
|
status,
|
||||||
|
headers: { 'Content-Type': 'text/event-stream' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response('Offline', {
|
||||||
|
status,
|
||||||
|
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
self.addEventListener('install', (e) => {
|
self.addEventListener('install', (e) => {
|
||||||
self.skipWaiting();
|
self.skipWaiting();
|
||||||
@@ -45,14 +75,21 @@ self.addEventListener('activate', (e) => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
self.addEventListener('fetch', (e) => {
|
self.addEventListener('fetch', (e) => {
|
||||||
const req = e.request;
|
const req = e.request;
|
||||||
|
|
||||||
// Always bypass service worker for non-GET and streaming routes
|
// Ignore non-HTTP(S) requests so extensions/browser-internal URLs are untouched.
|
||||||
if (isNetworkOnly(req)) {
|
if (!isHttpRequest(req)) {
|
||||||
e.respondWith(fetch(req));
|
return;
|
||||||
return;
|
}
|
||||||
}
|
|
||||||
|
// Always bypass service worker for non-GET and streaming routes
|
||||||
|
if (isNetworkOnly(req)) {
|
||||||
|
e.respondWith(
|
||||||
|
fetch(req).catch(() => fallbackResponse(req, 503))
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Cache-first for static assets
|
// Cache-first for static assets
|
||||||
if (isStaticAsset(req)) {
|
if (isStaticAsset(req)) {
|
||||||
@@ -65,15 +102,15 @@ self.addEventListener('fetch', (e) => {
|
|||||||
if (res && res.status === 200) cache.put(req, res.clone());
|
if (res && res.status === 200) cache.put(req, res.clone());
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
return cached;
|
return cached;
|
||||||
}
|
}
|
||||||
return fetch(req).then(res => {
|
return fetch(req).then(res => {
|
||||||
if (res && res.status === 200) cache.put(req, res.clone());
|
if (res && res.status === 200) cache.put(req, res.clone());
|
||||||
return res;
|
return res;
|
||||||
});
|
}).catch(() => fallbackResponse(req, 504));
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Network-first for HTML pages
|
// Network-first for HTML pages
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en" class="{% if offline_settings.tile_provider in ['cartodb_dark', 'cartodb_dark_cyan'] %}map-cyber-enabled{% elif offline_settings.tile_provider == 'cartodb_dark_flir' %}map-flir-enabled{% endif %}">
|
<html lang="en" class="{% if offline_settings.tile_provider in ['cartodb_dark', 'cartodb_dark_cyan'] %}map-cyber-enabled{% endif %}">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
@@ -423,9 +423,16 @@
|
|||||||
let alertsEnabled = true;
|
let alertsEnabled = true;
|
||||||
let detectionSoundEnabled = localStorage.getItem('adsb_detectionSound') !== 'false'; // Default on
|
let detectionSoundEnabled = localStorage.getItem('adsb_detectionSound') !== 'false'; // Default on
|
||||||
let soundedAircraft = {}; // Track aircraft we've played detection sound for
|
let soundedAircraft = {}; // Track aircraft we've played detection sound for
|
||||||
const MAP_CROSSHAIR_DURATION_MS = 620;
|
const MAP_CROSSHAIR_DURATION_MS = 1500;
|
||||||
|
const PANEL_SELECTION_BASE_ZOOM = 10;
|
||||||
|
const PANEL_SELECTION_MAX_ZOOM = 12;
|
||||||
|
const PANEL_SELECTION_ZOOM_INCREMENT = 1.4;
|
||||||
|
const PANEL_SELECTION_STAGE1_DURATION_SEC = 1.05;
|
||||||
|
const PANEL_SELECTION_STAGE2_DURATION_SEC = 1.15;
|
||||||
|
const PANEL_SELECTION_STAGE_GAP_MS = 180;
|
||||||
let mapCrosshairResetTimer = null;
|
let mapCrosshairResetTimer = null;
|
||||||
let mapCrosshairFallbackTimer = null;
|
let panelSelectionFallbackTimer = null;
|
||||||
|
let panelSelectionStageTimer = null;
|
||||||
let mapCrosshairRequestId = 0;
|
let mapCrosshairRequestId = 0;
|
||||||
// Watchlist - persisted to localStorage
|
// Watchlist - persisted to localStorage
|
||||||
let watchlist = JSON.parse(localStorage.getItem('adsb_watchlist') || '[]');
|
let watchlist = JSON.parse(localStorage.getItem('adsb_watchlist') || '[]');
|
||||||
@@ -2792,18 +2799,32 @@ sudo make install</code>
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function triggerMapCrosshairAnimation(lat, lon) {
|
function triggerMapCrosshairAnimation(lat, lon, durationMs = MAP_CROSSHAIR_DURATION_MS, lockToMapCenter = false) {
|
||||||
if (!radarMap) return;
|
if (!radarMap) return;
|
||||||
const overlay = document.getElementById('mapCrosshairOverlay');
|
const overlay = document.getElementById('mapCrosshairOverlay');
|
||||||
if (!overlay) return;
|
if (!overlay) return;
|
||||||
|
|
||||||
const point = radarMap.latLngToContainerPoint([lat, lon]);
|
|
||||||
const size = radarMap.getSize();
|
const size = radarMap.getSize();
|
||||||
const targetX = Math.max(0, Math.min(size.x, point.x));
|
let targetX;
|
||||||
const targetY = Math.max(0, Math.min(size.y, point.y));
|
let targetY;
|
||||||
|
|
||||||
overlay.style.setProperty('--target-x', `${targetX}px`);
|
if (lockToMapCenter) {
|
||||||
overlay.style.setProperty('--target-y', `${targetY}px`);
|
targetX = size.x / 2;
|
||||||
|
targetY = size.y / 2;
|
||||||
|
} else {
|
||||||
|
const point = radarMap.latLngToContainerPoint([lat, lon]);
|
||||||
|
targetX = Math.max(0, Math.min(size.x, point.x));
|
||||||
|
targetY = Math.max(0, Math.min(size.y, point.y));
|
||||||
|
}
|
||||||
|
|
||||||
|
const startX = size.x + 8;
|
||||||
|
const startY = size.y + 8;
|
||||||
|
|
||||||
|
overlay.style.setProperty('--crosshair-x-start', `${startX}px`);
|
||||||
|
overlay.style.setProperty('--crosshair-y-start', `${startY}px`);
|
||||||
|
overlay.style.setProperty('--crosshair-x-end', `${targetX}px`);
|
||||||
|
overlay.style.setProperty('--crosshair-y-end', `${targetY}px`);
|
||||||
|
overlay.style.setProperty('--crosshair-duration', `${durationMs}ms`);
|
||||||
overlay.classList.remove('active');
|
overlay.classList.remove('active');
|
||||||
void overlay.offsetWidth;
|
void overlay.offsetWidth;
|
||||||
overlay.classList.add('active');
|
overlay.classList.add('active');
|
||||||
@@ -2814,16 +2835,102 @@ sudo make install</code>
|
|||||||
mapCrosshairResetTimer = setTimeout(() => {
|
mapCrosshairResetTimer = setTimeout(() => {
|
||||||
overlay.classList.remove('active');
|
overlay.classList.remove('active');
|
||||||
mapCrosshairResetTimer = null;
|
mapCrosshairResetTimer = null;
|
||||||
}, MAP_CROSSHAIR_DURATION_MS + 40);
|
}, durationMs + 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPanelSelectionFinalZoom() {
|
||||||
|
if (!radarMap) return PANEL_SELECTION_BASE_ZOOM;
|
||||||
|
const currentZoom = radarMap.getZoom();
|
||||||
|
const maxZoom = typeof radarMap.getMaxZoom === 'function' ? radarMap.getMaxZoom() : PANEL_SELECTION_MAX_ZOOM;
|
||||||
|
return Math.min(
|
||||||
|
PANEL_SELECTION_MAX_ZOOM,
|
||||||
|
maxZoom,
|
||||||
|
Math.max(PANEL_SELECTION_BASE_ZOOM, currentZoom + PANEL_SELECTION_ZOOM_INCREMENT)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPanelSelectionIntermediateZoom(finalZoom) {
|
||||||
|
if (!radarMap) return finalZoom;
|
||||||
|
const currentZoom = radarMap.getZoom();
|
||||||
|
if (finalZoom - currentZoom < 0.8) {
|
||||||
|
return finalZoom;
|
||||||
|
}
|
||||||
|
const midpointZoom = currentZoom + ((finalZoom - currentZoom) * 0.55);
|
||||||
|
return Math.min(finalZoom - 0.45, midpointZoom);
|
||||||
|
}
|
||||||
|
|
||||||
|
function runPanelSelectionAnimation(lat, lon, requestId) {
|
||||||
|
if (!radarMap) return;
|
||||||
|
|
||||||
|
const finalZoom = getPanelSelectionFinalZoom();
|
||||||
|
const intermediateZoom = getPanelSelectionIntermediateZoom(finalZoom);
|
||||||
|
const sequenceDurationMs = Math.round(
|
||||||
|
((PANEL_SELECTION_STAGE1_DURATION_SEC + PANEL_SELECTION_STAGE2_DURATION_SEC) * 1000) +
|
||||||
|
PANEL_SELECTION_STAGE_GAP_MS + 260
|
||||||
|
);
|
||||||
|
const startSecondStage = () => {
|
||||||
|
if (requestId !== mapCrosshairRequestId) return;
|
||||||
|
radarMap.flyTo([lat, lon], finalZoom, {
|
||||||
|
animate: true,
|
||||||
|
duration: PANEL_SELECTION_STAGE2_DURATION_SEC,
|
||||||
|
easeLinearity: 0.2
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
triggerMapCrosshairAnimation(
|
||||||
|
lat,
|
||||||
|
lon,
|
||||||
|
Math.max(MAP_CROSSHAIR_DURATION_MS, sequenceDurationMs),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
|
if (intermediateZoom >= finalZoom - 0.1) {
|
||||||
|
radarMap.flyTo([lat, lon], finalZoom, {
|
||||||
|
animate: true,
|
||||||
|
duration: PANEL_SELECTION_STAGE2_DURATION_SEC,
|
||||||
|
easeLinearity: 0.2
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let stage1Handled = false;
|
||||||
|
const finishStage1 = () => {
|
||||||
|
if (stage1Handled || requestId !== mapCrosshairRequestId) return;
|
||||||
|
stage1Handled = true;
|
||||||
|
if (panelSelectionFallbackTimer) {
|
||||||
|
clearTimeout(panelSelectionFallbackTimer);
|
||||||
|
panelSelectionFallbackTimer = null;
|
||||||
|
}
|
||||||
|
panelSelectionStageTimer = setTimeout(() => {
|
||||||
|
panelSelectionStageTimer = null;
|
||||||
|
startSecondStage();
|
||||||
|
}, PANEL_SELECTION_STAGE_GAP_MS);
|
||||||
|
};
|
||||||
|
|
||||||
|
radarMap.once('moveend', finishStage1);
|
||||||
|
panelSelectionFallbackTimer = setTimeout(
|
||||||
|
finishStage1,
|
||||||
|
Math.round(PANEL_SELECTION_STAGE1_DURATION_SEC * 1000) + 160
|
||||||
|
);
|
||||||
|
|
||||||
|
radarMap.flyTo([lat, lon], intermediateZoom, {
|
||||||
|
animate: true,
|
||||||
|
duration: PANEL_SELECTION_STAGE1_DURATION_SEC,
|
||||||
|
easeLinearity: 0.2
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectAircraft(icao, source = 'map') {
|
function selectAircraft(icao, source = 'map') {
|
||||||
const prevSelected = selectedIcao;
|
const prevSelected = selectedIcao;
|
||||||
selectedIcao = icao;
|
selectedIcao = icao;
|
||||||
mapCrosshairRequestId += 1;
|
mapCrosshairRequestId += 1;
|
||||||
if (mapCrosshairFallbackTimer) {
|
if (panelSelectionFallbackTimer) {
|
||||||
clearTimeout(mapCrosshairFallbackTimer);
|
clearTimeout(panelSelectionFallbackTimer);
|
||||||
mapCrosshairFallbackTimer = null;
|
panelSelectionFallbackTimer = null;
|
||||||
|
}
|
||||||
|
if (panelSelectionStageTimer) {
|
||||||
|
clearTimeout(panelSelectionStageTimer);
|
||||||
|
panelSelectionStageTimer = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update marker icons for both previous and new selection
|
// Update marker icons for both previous and new selection
|
||||||
@@ -2853,19 +2960,8 @@ sudo make install</code>
|
|||||||
const targetLon = ac.lon;
|
const targetLon = ac.lon;
|
||||||
|
|
||||||
if (source === 'panel' && radarMap) {
|
if (source === 'panel' && radarMap) {
|
||||||
const requestId = mapCrosshairRequestId;
|
runPanelSelectionAnimation(targetLat, targetLon, mapCrosshairRequestId);
|
||||||
let crosshairTriggered = false;
|
return;
|
||||||
const runCrosshair = () => {
|
|
||||||
if (crosshairTriggered || requestId !== mapCrosshairRequestId) return;
|
|
||||||
crosshairTriggered = true;
|
|
||||||
if (mapCrosshairFallbackTimer) {
|
|
||||||
clearTimeout(mapCrosshairFallbackTimer);
|
|
||||||
mapCrosshairFallbackTimer = null;
|
|
||||||
}
|
|
||||||
triggerMapCrosshairAnimation(targetLat, targetLon);
|
|
||||||
};
|
|
||||||
radarMap.once('moveend', runCrosshair);
|
|
||||||
mapCrosshairFallbackTimer = setTimeout(runCrosshair, 450);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
radarMap.setView([targetLat, targetLon], 10);
|
radarMap.setView([targetLat, targetLon], 10);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en" class="{% if offline_settings.tile_provider in ['cartodb_dark', 'cartodb_dark_cyan'] %}map-cyber-enabled{% elif offline_settings.tile_provider == 'cartodb_dark_flir' %}map-flir-enabled{% endif %}">
|
<html lang="en" class="{% if offline_settings.tile_provider in ['cartodb_dark', 'cartodb_dark_cyan'] %}map-cyber-enabled{% endif %}">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en" class="{% if offline_settings.tile_provider in ['cartodb_dark', 'cartodb_dark_cyan'] %}map-cyber-enabled{% elif offline_settings.tile_provider == 'cartodb_dark_flir' %}map-flir-enabled{% endif %}">
|
<html lang="en" class="{% if offline_settings.tile_provider in ['cartodb_dark', 'cartodb_dark_cyan'] %}map-cyber-enabled{% endif %}">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en" class="{% if offline_settings.tile_provider in ['cartodb_dark', 'cartodb_dark_cyan'] %}map-cyber-enabled{% elif offline_settings.tile_provider == 'cartodb_dark_flir' %}map-flir-enabled{% endif %}">
|
<html lang="en" class="{% if offline_settings.tile_provider in ['cartodb_dark', 'cartodb_dark_cyan'] %}map-cyber-enabled{% endif %}">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en" class="{% if offline_settings.tile_provider in ['cartodb_dark', 'cartodb_dark_cyan'] %}map-cyber-enabled{% elif offline_settings.tile_provider == 'cartodb_dark_flir' %}map-flir-enabled{% endif %}">
|
<html lang="en" class="{% if offline_settings.tile_provider in ['cartodb_dark', 'cartodb_dark_cyan'] %}map-cyber-enabled{% endif %}">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
@@ -2193,7 +2193,7 @@
|
|||||||
<div id="sstvScopePanel" style="display: none; margin-bottom: 12px;">
|
<div id="sstvScopePanel" style="display: none; margin-bottom: 12px;">
|
||||||
<div style="background: #0a0a0a; border: 1px solid #1e1a2e; border-radius: 6px; padding: 8px 10px; font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;">
|
<div style="background: #0a0a0a; border: 1px solid #1e1a2e; border-radius: 6px; padding: 8px 10px; font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;">
|
||||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; font-size: 10px; color: #555; text-transform: uppercase; letter-spacing: 1px;">
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; font-size: 10px; color: #555; text-transform: uppercase; letter-spacing: 1px;">
|
||||||
<span>Signal Scope</span>
|
<span>Audio Waveform</span>
|
||||||
<div style="display: flex; gap: 14px;">
|
<div style="display: flex; gap: 14px;">
|
||||||
<span>RMS: <span id="sstvScopeRmsLabel" style="color: #c080ff; font-variant-numeric: tabular-nums;">0</span></span>
|
<span>RMS: <span id="sstvScopeRmsLabel" style="color: #c080ff; font-variant-numeric: tabular-nums;">0</span></span>
|
||||||
<span>PEAK: <span id="sstvScopePeakLabel" style="color: #f44; font-variant-numeric: tabular-nums;">0</span></span>
|
<span>PEAK: <span id="sstvScopePeakLabel" style="color: #f44; font-variant-numeric: tabular-nums;">0</span></span>
|
||||||
@@ -2461,7 +2461,7 @@
|
|||||||
<div id="sstvGeneralScopePanel" style="display: none; margin-bottom: 12px;">
|
<div id="sstvGeneralScopePanel" style="display: none; margin-bottom: 12px;">
|
||||||
<div style="background: #0a0a0a; border: 1px solid #1e1a2e; border-radius: 6px; padding: 8px 10px; font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;">
|
<div style="background: #0a0a0a; border: 1px solid #1e1a2e; border-radius: 6px; padding: 8px 10px; font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;">
|
||||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; font-size: 10px; color: #555; text-transform: uppercase; letter-spacing: 1px;">
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; font-size: 10px; color: #555; text-transform: uppercase; letter-spacing: 1px;">
|
||||||
<span>Signal Scope</span>
|
<span>Audio Waveform</span>
|
||||||
<div style="display: flex; gap: 14px;">
|
<div style="display: flex; gap: 14px;">
|
||||||
<span>RMS: <span id="sstvGeneralScopeRmsLabel" style="color: #c080ff; font-variant-numeric: tabular-nums;">0</span></span>
|
<span>RMS: <span id="sstvGeneralScopeRmsLabel" style="color: #c080ff; font-variant-numeric: tabular-nums;">0</span></span>
|
||||||
<span>PEAK: <span id="sstvGeneralScopePeakLabel" style="color: #f44; font-variant-numeric: tabular-nums;">0</span></span>
|
<span>PEAK: <span id="sstvGeneralScopePeakLabel" style="color: #f44; font-variant-numeric: tabular-nums;">0</span></span>
|
||||||
@@ -2835,7 +2835,7 @@
|
|||||||
<div id="pagerScopePanel" style="display: none; margin-bottom: 12px;">
|
<div id="pagerScopePanel" style="display: none; margin-bottom: 12px;">
|
||||||
<div style="background: #0a0a0a; border: 1px solid #1a1a2e; border-radius: 6px; padding: 8px 10px; font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;">
|
<div style="background: #0a0a0a; border: 1px solid #1a1a2e; border-radius: 6px; padding: 8px 10px; font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;">
|
||||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; font-size: 10px; color: #555; text-transform: uppercase; letter-spacing: 1px;">
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; font-size: 10px; color: #555; text-transform: uppercase; letter-spacing: 1px;">
|
||||||
<span>Signal Scope</span>
|
<span>Audio Waveform</span>
|
||||||
<div style="display: flex; gap: 14px;">
|
<div style="display: flex; gap: 14px;">
|
||||||
<span>RMS: <span id="scopeRmsLabel" style="color: #0ff; font-variant-numeric: tabular-nums;">0</span></span>
|
<span>RMS: <span id="scopeRmsLabel" style="color: #0ff; font-variant-numeric: tabular-nums;">0</span></span>
|
||||||
<span>PEAK: <span id="scopePeakLabel" style="color: #f44; font-variant-numeric: tabular-nums;">0</span></span>
|
<span>PEAK: <span id="scopePeakLabel" style="color: #f44; font-variant-numeric: tabular-nums;">0</span></span>
|
||||||
@@ -2853,7 +2853,7 @@
|
|||||||
<div id="sensorScopePanel" style="display: none; margin-bottom: 12px;">
|
<div id="sensorScopePanel" style="display: none; margin-bottom: 12px;">
|
||||||
<div style="background: #0a0a0a; border: 1px solid #1a2e1a; border-radius: 6px; padding: 8px 10px; font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;">
|
<div style="background: #0a0a0a; border: 1px solid #1a2e1a; border-radius: 6px; padding: 8px 10px; font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;">
|
||||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; font-size: 10px; color: #555; text-transform: uppercase; letter-spacing: 1px;">
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; font-size: 10px; color: #555; text-transform: uppercase; letter-spacing: 1px;">
|
||||||
<span>Signal Scope</span>
|
<span>Audio Waveform</span>
|
||||||
<div style="display: flex; gap: 14px;">
|
<div style="display: flex; gap: 14px;">
|
||||||
<span>RSSI: <span id="sensorScopeRssiLabel" style="color: #0f0; font-variant-numeric: tabular-nums;">--</span><span style="color: #444;"> dB</span></span>
|
<span>RSSI: <span id="sensorScopeRssiLabel" style="color: #0f0; font-variant-numeric: tabular-nums;">--</span><span style="color: #444;"> dB</span></span>
|
||||||
<span>SNR: <span id="sensorScopeSnrLabel" style="color: #fa0; font-variant-numeric: tabular-nums;">--</span><span style="color: #444;"> dB</span></span>
|
<span>SNR: <span id="sensorScopeSnrLabel" style="color: #fa0; font-variant-numeric: tabular-nums;">--</span><span style="color: #444;"> dB</span></span>
|
||||||
@@ -3934,61 +3934,157 @@
|
|||||||
let sensorScopeCtx = null;
|
let sensorScopeCtx = null;
|
||||||
let sensorScopeAnim = null;
|
let sensorScopeAnim = null;
|
||||||
let sensorScopeHistory = [];
|
let sensorScopeHistory = [];
|
||||||
|
let sensorScopeWaveBuffer = [];
|
||||||
|
let sensorScopeDisplayWave = [];
|
||||||
const SENSOR_SCOPE_LEN = 200;
|
const SENSOR_SCOPE_LEN = 200;
|
||||||
|
const SENSOR_SCOPE_WAVE_BUFFER_LEN = 2048;
|
||||||
|
const SENSOR_SCOPE_WAVE_INPUT_SMOOTH_ALPHA = 0.55;
|
||||||
|
const SENSOR_SCOPE_WAVE_DISPLAY_SMOOTH_ALPHA = 0.22;
|
||||||
|
const SENSOR_SCOPE_WAVE_IDLE_DECAY = 0.96;
|
||||||
let sensorScopeRssi = 0;
|
let sensorScopeRssi = 0;
|
||||||
let sensorScopeSnr = 0;
|
let sensorScopeSnr = 0;
|
||||||
let sensorScopeTargetRssi = 0;
|
let sensorScopeTargetRssi = 0;
|
||||||
let sensorScopeTargetSnr = 0;
|
let sensorScopeTargetSnr = 0;
|
||||||
let sensorScopeMsgBurst = 0;
|
let sensorScopeMsgBurst = 0;
|
||||||
let sensorScopeLastPulse = 0;
|
let sensorScopeLastWaveAt = 0;
|
||||||
|
let sensorScopeLastInputSample = 0;
|
||||||
|
|
||||||
|
function resizeSensorScopeCanvas(canvas) {
|
||||||
|
if (!canvas) return;
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
const width = Math.max(1, Math.floor(rect.width * dpr));
|
||||||
|
const height = Math.max(1, Math.floor(rect.height * dpr));
|
||||||
|
if (canvas.width !== width || canvas.height !== height) {
|
||||||
|
canvas.width = width;
|
||||||
|
canvas.height = height;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSensorWaveformFallback(rssi, snr, noise, points = 160) {
|
||||||
|
const rssiNorm = Math.min(Math.max(Math.abs(rssi) / 40, 0), 1);
|
||||||
|
const snrNorm = Math.min(Math.max((snr + 5) / 35, 0), 1);
|
||||||
|
const noiseNorm = Math.min(Math.max(Math.abs(noise) / 40, 0), 1);
|
||||||
|
|
||||||
|
const amplitude = Math.max(0.06, Math.min(1.0, (0.6 * rssiNorm + 0.4 * snrNorm) - (0.22 * noiseNorm)));
|
||||||
|
const cycles = 3 + (snrNorm * 8);
|
||||||
|
const harmonic = 0.25 + (0.35 * snrNorm);
|
||||||
|
const hiss = 0.08 + (0.18 * noiseNorm);
|
||||||
|
const phase = (performance.now() * 0.002 * (1.4 + (snrNorm * 2.2))) % (Math.PI * 2);
|
||||||
|
|
||||||
|
const waveform = [];
|
||||||
|
for (let i = 0; i < points; i++) {
|
||||||
|
const t = points > 1 ? (i / (points - 1)) : 0;
|
||||||
|
const base = Math.sin((Math.PI * 2 * cycles * t) + phase);
|
||||||
|
const overtone = Math.sin((Math.PI * 2 * (cycles * 2.4) * t) + (phase * 0.7));
|
||||||
|
const noiseWobble = Math.sin((Math.PI * 2 * (cycles * 7.0) * t) + (phase * 2.1));
|
||||||
|
let sample = amplitude * (base + (harmonic * overtone) + (hiss * noiseWobble));
|
||||||
|
sample /= (1 + harmonic + hiss);
|
||||||
|
waveform.push(Math.round(Math.max(-1, Math.min(1, sample)) * 127));
|
||||||
|
}
|
||||||
|
return waveform;
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendSensorWaveformSamples(waveform) {
|
||||||
|
if (!Array.isArray(waveform) || waveform.length === 0) return;
|
||||||
|
|
||||||
|
for (const packedSample of waveform) {
|
||||||
|
const sample = Number(packedSample);
|
||||||
|
if (!Number.isFinite(sample)) continue;
|
||||||
|
const normalized = Math.max(-127, Math.min(127, sample)) / 127;
|
||||||
|
sensorScopeLastInputSample += (normalized - sensorScopeLastInputSample) * SENSOR_SCOPE_WAVE_INPUT_SMOOTH_ALPHA;
|
||||||
|
sensorScopeWaveBuffer.push(sensorScopeLastInputSample);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sensorScopeWaveBuffer.length > SENSOR_SCOPE_WAVE_BUFFER_LEN) {
|
||||||
|
sensorScopeWaveBuffer.splice(0, sensorScopeWaveBuffer.length - SENSOR_SCOPE_WAVE_BUFFER_LEN);
|
||||||
|
}
|
||||||
|
sensorScopeLastWaveAt = performance.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
function applySensorScopeData(scopeData) {
|
||||||
|
if (!scopeData || typeof scopeData !== 'object') return;
|
||||||
|
|
||||||
|
const parsedRssi = Number(scopeData.rssi);
|
||||||
|
const parsedSnr = Number(scopeData.snr);
|
||||||
|
const parsedNoise = Number(scopeData.noise);
|
||||||
|
|
||||||
|
const rssi = Number.isFinite(parsedRssi) ? parsedRssi : 0;
|
||||||
|
const snr = Number.isFinite(parsedSnr) ? parsedSnr : 0;
|
||||||
|
const noise = Number.isFinite(parsedNoise) ? parsedNoise : 0;
|
||||||
|
|
||||||
|
sensorScopeTargetRssi = rssi;
|
||||||
|
sensorScopeTargetSnr = snr;
|
||||||
|
|
||||||
|
if (Array.isArray(scopeData.waveform) && scopeData.waveform.length) {
|
||||||
|
appendSensorWaveformSamples(scopeData.waveform);
|
||||||
|
} else {
|
||||||
|
appendSensorWaveformSamples(buildSensorWaveformFallback(rssi, snr, noise));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function initSensorScope() {
|
function initSensorScope() {
|
||||||
const canvas = document.getElementById('sensorScopeCanvas');
|
const canvas = document.getElementById('sensorScopeCanvas');
|
||||||
if (!canvas) return;
|
if (!canvas) return;
|
||||||
const rect = canvas.getBoundingClientRect();
|
|
||||||
canvas.width = rect.width * (window.devicePixelRatio || 1);
|
if (sensorScopeAnim) {
|
||||||
canvas.height = rect.height * (window.devicePixelRatio || 1);
|
cancelAnimationFrame(sensorScopeAnim);
|
||||||
|
sensorScopeAnim = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
resizeSensorScopeCanvas(canvas);
|
||||||
sensorScopeCtx = canvas.getContext('2d');
|
sensorScopeCtx = canvas.getContext('2d');
|
||||||
sensorScopeHistory = new Array(SENSOR_SCOPE_LEN).fill(0);
|
sensorScopeHistory = new Array(SENSOR_SCOPE_LEN).fill(0);
|
||||||
|
sensorScopeWaveBuffer = [];
|
||||||
|
sensorScopeDisplayWave = [];
|
||||||
sensorScopeRssi = 0;
|
sensorScopeRssi = 0;
|
||||||
sensorScopeSnr = 0;
|
sensorScopeSnr = 0;
|
||||||
sensorScopeTargetRssi = 0;
|
sensorScopeTargetRssi = 0;
|
||||||
sensorScopeTargetSnr = 0;
|
sensorScopeTargetSnr = 0;
|
||||||
sensorScopeMsgBurst = 0;
|
sensorScopeMsgBurst = 0;
|
||||||
sensorScopeLastPulse = 0;
|
sensorScopeLastWaveAt = 0;
|
||||||
|
sensorScopeLastInputSample = 0;
|
||||||
drawSensorScope();
|
drawSensorScope();
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawSensorScope() {
|
function drawSensorScope() {
|
||||||
const ctx = sensorScopeCtx;
|
const ctx = sensorScopeCtx;
|
||||||
if (!ctx) return;
|
if (!ctx) return;
|
||||||
|
|
||||||
|
resizeSensorScopeCanvas(ctx.canvas);
|
||||||
const W = ctx.canvas.width;
|
const W = ctx.canvas.width;
|
||||||
const H = ctx.canvas.height;
|
const H = ctx.canvas.height;
|
||||||
const midY = H / 2;
|
const midY = H / 2;
|
||||||
|
|
||||||
// Phosphor persistence
|
// Phosphor persistence
|
||||||
ctx.fillStyle = 'rgba(5, 5, 16, 0.3)';
|
ctx.fillStyle = 'rgba(5, 5, 16, 0.26)';
|
||||||
ctx.fillRect(0, 0, W, H);
|
ctx.fillRect(0, 0, W, H);
|
||||||
|
|
||||||
// Smooth towards targets (decay when no new packets)
|
// Smooth towards targets
|
||||||
sensorScopeRssi += (sensorScopeTargetRssi - sensorScopeRssi) * 0.25;
|
sensorScopeRssi += (sensorScopeTargetRssi - sensorScopeRssi) * 0.25;
|
||||||
sensorScopeSnr += (sensorScopeTargetSnr - sensorScopeSnr) * 0.15;
|
sensorScopeSnr += (sensorScopeTargetSnr - sensorScopeSnr) * 0.15;
|
||||||
|
|
||||||
// Decay targets back to zero between packets
|
// Decay targets back to idle between updates
|
||||||
sensorScopeTargetRssi *= 0.97;
|
sensorScopeTargetRssi *= 0.97;
|
||||||
sensorScopeTargetSnr *= 0.97;
|
sensorScopeTargetSnr *= 0.97;
|
||||||
|
|
||||||
// RSSI is typically negative dBm (e.g. -0.1 to -30+)
|
// Keep amplitude envelope for context
|
||||||
// Normalize: map absolute RSSI to 0-1 range (0 dB = max, -40 dB = min)
|
|
||||||
const rssiNorm = Math.min(Math.max(Math.abs(sensorScopeRssi) / 40, 0), 1.0);
|
const rssiNorm = Math.min(Math.max(Math.abs(sensorScopeRssi) / 40, 0), 1.0);
|
||||||
sensorScopeHistory.push(rssiNorm);
|
sensorScopeHistory.push(rssiNorm);
|
||||||
if (sensorScopeHistory.length > SENSOR_SCOPE_LEN) {
|
if (sensorScopeHistory.length > SENSOR_SCOPE_LEN) {
|
||||||
sensorScopeHistory.shift();
|
sensorScopeHistory.shift();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Grid lines
|
// Grid lines (horizontal + vertical)
|
||||||
ctx.strokeStyle = 'rgba(40, 80, 40, 0.4)';
|
ctx.strokeStyle = 'rgba(40, 80, 40, 0.4)';
|
||||||
ctx.lineWidth = 1;
|
ctx.lineWidth = 0.8;
|
||||||
|
for (let i = 1; i < 8; i++) {
|
||||||
|
const gx = (W / 8) * i;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(gx, 0);
|
||||||
|
ctx.lineTo(gx, H);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
for (let g = 0.25; g < 1; g += 0.25) {
|
for (let g = 0.25; g < 1; g += 0.25) {
|
||||||
const gy = midY - g * midY;
|
const gy = midY - g * midY;
|
||||||
const gy2 = midY + g * midY;
|
const gy2 = midY + g * midY;
|
||||||
@@ -4000,40 +4096,92 @@
|
|||||||
|
|
||||||
// Center baseline
|
// Center baseline
|
||||||
ctx.strokeStyle = 'rgba(60, 100, 60, 0.5)';
|
ctx.strokeStyle = 'rgba(60, 100, 60, 0.5)';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.moveTo(0, midY);
|
ctx.moveTo(0, midY);
|
||||||
ctx.lineTo(W, midY);
|
ctx.lineTo(W, midY);
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
|
|
||||||
// Waveform (mirrored, green theme for 433)
|
// Slow envelope as context around baseline
|
||||||
const stepX = W / SENSOR_SCOPE_LEN;
|
const envStepX = W / (SENSOR_SCOPE_LEN - 1);
|
||||||
ctx.strokeStyle = '#0f0';
|
ctx.strokeStyle = 'rgba(86, 230, 120, 0.45)';
|
||||||
ctx.lineWidth = 1.5;
|
ctx.lineWidth = 1;
|
||||||
ctx.shadowColor = '#0f0';
|
|
||||||
ctx.shadowBlur = 4;
|
|
||||||
|
|
||||||
// Upper half
|
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
for (let i = 0; i < sensorScopeHistory.length; i++) {
|
for (let i = 0; i < sensorScopeHistory.length; i++) {
|
||||||
const x = i * stepX;
|
const x = i * envStepX;
|
||||||
const amp = sensorScopeHistory[i] * midY * 0.9;
|
const amp = sensorScopeHistory[i] * midY * 0.85;
|
||||||
const y = midY - amp;
|
const y = midY - amp;
|
||||||
if (i === 0) ctx.moveTo(x, y);
|
if (i === 0) ctx.moveTo(x, y);
|
||||||
else ctx.lineTo(x, y);
|
else ctx.lineTo(x, y);
|
||||||
}
|
}
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
|
|
||||||
// Lower half (mirror)
|
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
for (let i = 0; i < sensorScopeHistory.length; i++) {
|
for (let i = 0; i < sensorScopeHistory.length; i++) {
|
||||||
const x = i * stepX;
|
const x = i * envStepX;
|
||||||
const amp = sensorScopeHistory[i] * midY * 0.9;
|
const amp = sensorScopeHistory[i] * midY * 0.85;
|
||||||
const y = midY + amp;
|
const y = midY + amp;
|
||||||
if (i === 0) ctx.moveTo(x, y);
|
if (i === 0) ctx.moveTo(x, y);
|
||||||
else ctx.lineTo(x, y);
|
else ctx.lineTo(x, y);
|
||||||
}
|
}
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Actual waveform trace
|
||||||
|
const waveformPointCount = Math.min(Math.max(120, Math.floor(W / 3.2)), 420);
|
||||||
|
if (sensorScopeWaveBuffer.length > 1) {
|
||||||
|
const waveIsFresh = (performance.now() - sensorScopeLastWaveAt) < 1000;
|
||||||
|
const sourceLen = sensorScopeWaveBuffer.length;
|
||||||
|
const sourceWindow = Math.min(sourceLen, 1536);
|
||||||
|
const sourceStart = sourceLen - sourceWindow;
|
||||||
|
|
||||||
|
if (sensorScopeDisplayWave.length !== waveformPointCount) {
|
||||||
|
sensorScopeDisplayWave = new Array(waveformPointCount).fill(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < waveformPointCount; i++) {
|
||||||
|
const a = sourceStart + Math.floor((i / waveformPointCount) * sourceWindow);
|
||||||
|
const b = sourceStart + Math.floor(((i + 1) / waveformPointCount) * sourceWindow);
|
||||||
|
const start = Math.max(sourceStart, Math.min(sourceLen - 1, a));
|
||||||
|
const end = Math.max(start + 1, Math.min(sourceLen, b));
|
||||||
|
|
||||||
|
let sum = 0;
|
||||||
|
let count = 0;
|
||||||
|
for (let j = start; j < end; j++) {
|
||||||
|
sum += sensorScopeWaveBuffer[j];
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
const targetSample = count > 0 ? (sum / count) : 0;
|
||||||
|
sensorScopeDisplayWave[i] += (targetSample - sensorScopeDisplayWave[i]) * SENSOR_SCOPE_WAVE_DISPLAY_SMOOTH_ALPHA;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.strokeStyle = waveIsFresh ? '#40ff7a' : 'rgba(64, 255, 122, 0.45)';
|
||||||
|
ctx.lineWidth = 1.7;
|
||||||
|
ctx.shadowColor = '#40ff7a';
|
||||||
|
ctx.shadowBlur = waveIsFresh ? 6 : 2;
|
||||||
|
|
||||||
|
const stepX = waveformPointCount > 1 ? (W / (waveformPointCount - 1)) : W;
|
||||||
|
ctx.beginPath();
|
||||||
|
const firstY = midY - (sensorScopeDisplayWave[0] * midY * 0.9);
|
||||||
|
ctx.moveTo(0, firstY);
|
||||||
|
for (let i = 1; i < waveformPointCount - 1; i++) {
|
||||||
|
const x = i * stepX;
|
||||||
|
const y = midY - (sensorScopeDisplayWave[i] * midY * 0.9);
|
||||||
|
const nx = (i + 1) * stepX;
|
||||||
|
const ny = midY - (sensorScopeDisplayWave[i + 1] * midY * 0.9);
|
||||||
|
const cx = (x + nx) / 2;
|
||||||
|
const cy = (y + ny) / 2;
|
||||||
|
ctx.quadraticCurveTo(x, y, cx, cy);
|
||||||
|
}
|
||||||
|
const lastX = (waveformPointCount - 1) * stepX;
|
||||||
|
const lastY = midY - (sensorScopeDisplayWave[waveformPointCount - 1] * midY * 0.9);
|
||||||
|
ctx.lineTo(lastX, lastY);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
if (!waveIsFresh) {
|
||||||
|
for (let i = 0; i < sensorScopeDisplayWave.length; i++) {
|
||||||
|
sensorScopeDisplayWave[i] *= SENSOR_SCOPE_WAVE_IDLE_DECAY;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
ctx.shadowBlur = 0;
|
ctx.shadowBlur = 0;
|
||||||
|
|
||||||
// SNR indicator (amber dashed line)
|
// SNR indicator (amber dashed line)
|
||||||
@@ -4050,7 +4198,7 @@
|
|||||||
ctx.setLineDash([]);
|
ctx.setLineDash([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sensor decode flash (green overlay)
|
// Sensor decode flash
|
||||||
if (sensorScopeMsgBurst > 0.01) {
|
if (sensorScopeMsgBurst > 0.01) {
|
||||||
ctx.fillStyle = `rgba(0, 255, 100, ${sensorScopeMsgBurst * 0.15})`;
|
ctx.fillStyle = `rgba(0, 255, 100, ${sensorScopeMsgBurst * 0.15})`;
|
||||||
ctx.fillRect(0, 0, W, H);
|
ctx.fillRect(0, 0, W, H);
|
||||||
@@ -4064,11 +4212,15 @@
|
|||||||
if (rssiLabel) rssiLabel.textContent = sensorScopeRssi < -0.5 ? sensorScopeRssi.toFixed(1) : '--';
|
if (rssiLabel) rssiLabel.textContent = sensorScopeRssi < -0.5 ? sensorScopeRssi.toFixed(1) : '--';
|
||||||
if (snrLabel) snrLabel.textContent = sensorScopeSnr > 0.5 ? sensorScopeSnr.toFixed(1) : '--';
|
if (snrLabel) snrLabel.textContent = sensorScopeSnr > 0.5 ? sensorScopeSnr.toFixed(1) : '--';
|
||||||
if (statusLabel) {
|
if (statusLabel) {
|
||||||
if (Math.abs(sensorScopeRssi) > 1) {
|
const waveIsFresh = (performance.now() - sensorScopeLastWaveAt) < 1000;
|
||||||
statusLabel.textContent = 'SIGNAL';
|
if (Math.abs(sensorScopeRssi) > 1.2 && waveIsFresh) {
|
||||||
statusLabel.style.color = '#0f0';
|
statusLabel.textContent = 'DEMODULATING';
|
||||||
|
statusLabel.style.color = '#40ff7a';
|
||||||
|
} else if (Math.abs(sensorScopeRssi) > 0.6) {
|
||||||
|
statusLabel.textContent = 'CARRIER';
|
||||||
|
statusLabel.style.color = '#78ff9a';
|
||||||
} else {
|
} else {
|
||||||
statusLabel.textContent = 'MONITORING';
|
statusLabel.textContent = 'QUIET';
|
||||||
statusLabel.style.color = '#555';
|
statusLabel.style.color = '#555';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4082,6 +4234,11 @@
|
|||||||
sensorScopeAnim = null;
|
sensorScopeAnim = null;
|
||||||
}
|
}
|
||||||
sensorScopeCtx = null;
|
sensorScopeCtx = null;
|
||||||
|
sensorScopeWaveBuffer = [];
|
||||||
|
sensorScopeDisplayWave = [];
|
||||||
|
sensorScopeHistory = [];
|
||||||
|
sensorScopeLastWaveAt = 0;
|
||||||
|
sensorScopeLastInputSample = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start sensor decoding
|
// Start sensor decoding
|
||||||
@@ -4256,6 +4413,11 @@
|
|||||||
const placeholder = output.querySelector('.placeholder');
|
const placeholder = output.querySelector('.placeholder');
|
||||||
if (placeholder) placeholder.style.display = 'none';
|
if (placeholder) placeholder.style.display = 'none';
|
||||||
|
|
||||||
|
// Agent polling may only return decoded packets, so derive scope updates from packet levels.
|
||||||
|
if (msg && (msg.rssi !== undefined || msg.snr !== undefined || msg.noise !== undefined)) {
|
||||||
|
applySensorScopeData(msg);
|
||||||
|
}
|
||||||
|
|
||||||
// Create signal card if SignalCards is available
|
// Create signal card if SignalCards is available
|
||||||
if (typeof SignalCards !== 'undefined' && SignalCards.createFromSensor) {
|
if (typeof SignalCards !== 'undefined' && SignalCards.createFromSensor) {
|
||||||
const card = SignalCards.createFromSensor(msg);
|
const card = SignalCards.createFromSensor(msg);
|
||||||
@@ -4303,8 +4465,7 @@
|
|||||||
if (data.type === 'sensor') {
|
if (data.type === 'sensor') {
|
||||||
addSensorReading(data);
|
addSensorReading(data);
|
||||||
} else if (data.type === 'scope') {
|
} else if (data.type === 'scope') {
|
||||||
sensorScopeTargetRssi = data.rssi;
|
applySensorScopeData(data);
|
||||||
sensorScopeTargetSnr = data.snr;
|
|
||||||
} else if (data.type === 'status') {
|
} else if (data.type === 'status') {
|
||||||
if (data.text === 'stopped') {
|
if (data.text === 'stopped') {
|
||||||
setSensorRunning(false);
|
setSensorRunning(false);
|
||||||
@@ -4332,6 +4493,12 @@
|
|||||||
// Flash sensor scope green on decode
|
// Flash sensor scope green on decode
|
||||||
sensorScopeMsgBurst = 1.0;
|
sensorScopeMsgBurst = 1.0;
|
||||||
|
|
||||||
|
// Fallback when no dedicated scope packet has arrived recently.
|
||||||
|
if ((data.rssi !== undefined || data.snr !== undefined || data.noise !== undefined)
|
||||||
|
&& ((performance.now() - sensorScopeLastWaveAt) > 250)) {
|
||||||
|
applySensorScopeData(data);
|
||||||
|
}
|
||||||
|
|
||||||
sensorCount++;
|
sensorCount++;
|
||||||
document.getElementById('sensorCount').textContent = sensorCount;
|
document.getElementById('sensorCount').textContent = sensorCount;
|
||||||
|
|
||||||
@@ -5208,54 +5375,111 @@
|
|||||||
let pagerScopeCtx = null;
|
let pagerScopeCtx = null;
|
||||||
let pagerScopeAnim = null;
|
let pagerScopeAnim = null;
|
||||||
let pagerScopeHistory = [];
|
let pagerScopeHistory = [];
|
||||||
|
let pagerScopeWaveBuffer = [];
|
||||||
|
let pagerScopeDisplayWave = [];
|
||||||
const SCOPE_HISTORY_LEN = 200;
|
const SCOPE_HISTORY_LEN = 200;
|
||||||
|
const SCOPE_WAVE_BUFFER_LEN = 2048;
|
||||||
|
const SCOPE_WAVE_INPUT_SMOOTH_ALPHA = 0.55;
|
||||||
|
const SCOPE_WAVE_DISPLAY_SMOOTH_ALPHA = 0.22;
|
||||||
|
const SCOPE_WAVE_IDLE_DECAY = 0.96;
|
||||||
let pagerScopeRms = 0;
|
let pagerScopeRms = 0;
|
||||||
let pagerScopePeak = 0;
|
let pagerScopePeak = 0;
|
||||||
let pagerScopeTargetRms = 0;
|
let pagerScopeTargetRms = 0;
|
||||||
let pagerScopeTargetPeak = 0;
|
let pagerScopeTargetPeak = 0;
|
||||||
let pagerScopeMsgBurst = 0;
|
let pagerScopeMsgBurst = 0;
|
||||||
|
let pagerScopeLastWaveAt = 0;
|
||||||
|
let pagerScopeLastInputSample = 0;
|
||||||
|
|
||||||
|
function resizePagerScopeCanvas(canvas) {
|
||||||
|
if (!canvas) return;
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
const width = Math.max(1, Math.floor(rect.width * dpr));
|
||||||
|
const height = Math.max(1, Math.floor(rect.height * dpr));
|
||||||
|
if (canvas.width !== width || canvas.height !== height) {
|
||||||
|
canvas.width = width;
|
||||||
|
canvas.height = height;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyPagerScopeData(scopeData) {
|
||||||
|
if (!scopeData || typeof scopeData !== 'object') return;
|
||||||
|
|
||||||
|
pagerScopeTargetRms = Number(scopeData.rms) || 0;
|
||||||
|
pagerScopeTargetPeak = Number(scopeData.peak) || 0;
|
||||||
|
|
||||||
|
if (Array.isArray(scopeData.waveform) && scopeData.waveform.length) {
|
||||||
|
for (const packedSample of scopeData.waveform) {
|
||||||
|
const sample = Number(packedSample);
|
||||||
|
if (!Number.isFinite(sample)) continue;
|
||||||
|
const normalized = Math.max(-127, Math.min(127, sample)) / 127;
|
||||||
|
pagerScopeLastInputSample += (normalized - pagerScopeLastInputSample) * SCOPE_WAVE_INPUT_SMOOTH_ALPHA;
|
||||||
|
pagerScopeWaveBuffer.push(pagerScopeLastInputSample);
|
||||||
|
}
|
||||||
|
if (pagerScopeWaveBuffer.length > SCOPE_WAVE_BUFFER_LEN) {
|
||||||
|
pagerScopeWaveBuffer.splice(0, pagerScopeWaveBuffer.length - SCOPE_WAVE_BUFFER_LEN);
|
||||||
|
}
|
||||||
|
pagerScopeLastWaveAt = performance.now();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function initPagerScope() {
|
function initPagerScope() {
|
||||||
const canvas = document.getElementById('pagerScopeCanvas');
|
const canvas = document.getElementById('pagerScopeCanvas');
|
||||||
if (!canvas) return;
|
if (!canvas) return;
|
||||||
// Set actual pixel resolution
|
|
||||||
const rect = canvas.getBoundingClientRect();
|
if (pagerScopeAnim) {
|
||||||
canvas.width = rect.width * (window.devicePixelRatio || 1);
|
cancelAnimationFrame(pagerScopeAnim);
|
||||||
canvas.height = rect.height * (window.devicePixelRatio || 1);
|
pagerScopeAnim = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
resizePagerScopeCanvas(canvas);
|
||||||
pagerScopeCtx = canvas.getContext('2d');
|
pagerScopeCtx = canvas.getContext('2d');
|
||||||
pagerScopeHistory = new Array(SCOPE_HISTORY_LEN).fill(0);
|
pagerScopeHistory = new Array(SCOPE_HISTORY_LEN).fill(0);
|
||||||
|
pagerScopeWaveBuffer = [];
|
||||||
|
pagerScopeDisplayWave = [];
|
||||||
pagerScopeRms = 0;
|
pagerScopeRms = 0;
|
||||||
pagerScopePeak = 0;
|
pagerScopePeak = 0;
|
||||||
pagerScopeTargetRms = 0;
|
pagerScopeTargetRms = 0;
|
||||||
pagerScopeTargetPeak = 0;
|
pagerScopeTargetPeak = 0;
|
||||||
pagerScopeMsgBurst = 0;
|
pagerScopeMsgBurst = 0;
|
||||||
|
pagerScopeLastWaveAt = 0;
|
||||||
|
pagerScopeLastInputSample = 0;
|
||||||
drawPagerScope();
|
drawPagerScope();
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawPagerScope() {
|
function drawPagerScope() {
|
||||||
const ctx = pagerScopeCtx;
|
const ctx = pagerScopeCtx;
|
||||||
if (!ctx) return;
|
if (!ctx) return;
|
||||||
|
|
||||||
|
resizePagerScopeCanvas(ctx.canvas);
|
||||||
const W = ctx.canvas.width;
|
const W = ctx.canvas.width;
|
||||||
const H = ctx.canvas.height;
|
const H = ctx.canvas.height;
|
||||||
const midY = H / 2;
|
const midY = H / 2;
|
||||||
|
|
||||||
// Phosphor persistence: semi-transparent clear
|
// Phosphor persistence
|
||||||
ctx.fillStyle = 'rgba(5, 5, 16, 0.3)';
|
ctx.fillStyle = 'rgba(5, 5, 16, 0.26)';
|
||||||
ctx.fillRect(0, 0, W, H);
|
ctx.fillRect(0, 0, W, H);
|
||||||
|
|
||||||
// Smooth towards target values
|
// Smooth towards target values
|
||||||
pagerScopeRms += (pagerScopeTargetRms - pagerScopeRms) * 0.25;
|
pagerScopeRms += (pagerScopeTargetRms - pagerScopeRms) * 0.25;
|
||||||
pagerScopePeak += (pagerScopeTargetPeak - pagerScopePeak) * 0.15;
|
pagerScopePeak += (pagerScopeTargetPeak - pagerScopePeak) * 0.15;
|
||||||
|
|
||||||
// Push current RMS into history (normalized 0-1 against 32768)
|
// Keep a slow amplitude envelope for readability
|
||||||
pagerScopeHistory.push(Math.min(pagerScopeRms / 32768, 1.0));
|
pagerScopeHistory.push(Math.min(pagerScopeRms / 32768, 1.0));
|
||||||
if (pagerScopeHistory.length > SCOPE_HISTORY_LEN) {
|
if (pagerScopeHistory.length > SCOPE_HISTORY_LEN) {
|
||||||
pagerScopeHistory.shift();
|
pagerScopeHistory.shift();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Grid lines
|
// Grid lines (horizontal + vertical)
|
||||||
ctx.strokeStyle = 'rgba(40, 40, 80, 0.4)';
|
ctx.strokeStyle = 'rgba(40, 40, 80, 0.4)';
|
||||||
ctx.lineWidth = 1;
|
ctx.lineWidth = 0.8;
|
||||||
|
for (let i = 1; i < 8; i++) {
|
||||||
|
const gx = (W / 8) * i;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(gx, 0);
|
||||||
|
ctx.lineTo(gx, H);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
for (let g = 0.25; g < 1; g += 0.25) {
|
for (let g = 0.25; g < 1; g += 0.25) {
|
||||||
const gy = midY - g * midY;
|
const gy = midY - g * midY;
|
||||||
const gy2 = midY + g * midY;
|
const gy2 = midY + g * midY;
|
||||||
@@ -5267,40 +5491,92 @@
|
|||||||
|
|
||||||
// Center baseline
|
// Center baseline
|
||||||
ctx.strokeStyle = 'rgba(60, 60, 100, 0.5)';
|
ctx.strokeStyle = 'rgba(60, 60, 100, 0.5)';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.moveTo(0, midY);
|
ctx.moveTo(0, midY);
|
||||||
ctx.lineTo(W, midY);
|
ctx.lineTo(W, midY);
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
|
|
||||||
// Waveform (mirrored)
|
// Slow envelope as context around baseline
|
||||||
const stepX = W / SCOPE_HISTORY_LEN;
|
const envStepX = W / (SCOPE_HISTORY_LEN - 1);
|
||||||
ctx.strokeStyle = '#0ff';
|
ctx.strokeStyle = 'rgba(90, 180, 255, 0.45)';
|
||||||
ctx.lineWidth = 1.5;
|
ctx.lineWidth = 1;
|
||||||
ctx.shadowColor = '#0ff';
|
|
||||||
ctx.shadowBlur = 4;
|
|
||||||
|
|
||||||
// Upper half
|
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
for (let i = 0; i < pagerScopeHistory.length; i++) {
|
for (let i = 0; i < pagerScopeHistory.length; i++) {
|
||||||
const x = i * stepX;
|
const x = i * envStepX;
|
||||||
const amp = pagerScopeHistory[i] * midY * 0.9;
|
const amp = pagerScopeHistory[i] * midY * 0.85;
|
||||||
const y = midY - amp;
|
const y = midY - amp;
|
||||||
if (i === 0) ctx.moveTo(x, y);
|
if (i === 0) ctx.moveTo(x, y);
|
||||||
else ctx.lineTo(x, y);
|
else ctx.lineTo(x, y);
|
||||||
}
|
}
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
|
|
||||||
// Lower half (mirror)
|
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
for (let i = 0; i < pagerScopeHistory.length; i++) {
|
for (let i = 0; i < pagerScopeHistory.length; i++) {
|
||||||
const x = i * stepX;
|
const x = i * envStepX;
|
||||||
const amp = pagerScopeHistory[i] * midY * 0.9;
|
const amp = pagerScopeHistory[i] * midY * 0.85;
|
||||||
const y = midY + amp;
|
const y = midY + amp;
|
||||||
if (i === 0) ctx.moveTo(x, y);
|
if (i === 0) ctx.moveTo(x, y);
|
||||||
else ctx.lineTo(x, y);
|
else ctx.lineTo(x, y);
|
||||||
}
|
}
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Actual waveform from real incoming audio samples
|
||||||
|
const waveformPointCount = Math.min(Math.max(120, Math.floor(W / 3.2)), 420);
|
||||||
|
if (pagerScopeWaveBuffer.length > 1) {
|
||||||
|
const waveIsFresh = (performance.now() - pagerScopeLastWaveAt) < 700;
|
||||||
|
const sourceLen = pagerScopeWaveBuffer.length;
|
||||||
|
const sourceWindow = Math.min(sourceLen, 1536);
|
||||||
|
const sourceStart = sourceLen - sourceWindow;
|
||||||
|
|
||||||
|
if (pagerScopeDisplayWave.length !== waveformPointCount) {
|
||||||
|
pagerScopeDisplayWave = new Array(waveformPointCount).fill(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < waveformPointCount; i++) {
|
||||||
|
const a = sourceStart + Math.floor((i / waveformPointCount) * sourceWindow);
|
||||||
|
const b = sourceStart + Math.floor(((i + 1) / waveformPointCount) * sourceWindow);
|
||||||
|
const start = Math.max(sourceStart, Math.min(sourceLen - 1, a));
|
||||||
|
const end = Math.max(start + 1, Math.min(sourceLen, b));
|
||||||
|
|
||||||
|
let sum = 0;
|
||||||
|
let count = 0;
|
||||||
|
for (let j = start; j < end; j++) {
|
||||||
|
sum += pagerScopeWaveBuffer[j];
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
const targetSample = count > 0 ? (sum / count) : 0;
|
||||||
|
pagerScopeDisplayWave[i] += (targetSample - pagerScopeDisplayWave[i]) * SCOPE_WAVE_DISPLAY_SMOOTH_ALPHA;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.strokeStyle = waveIsFresh ? '#2efbff' : 'rgba(46, 251, 255, 0.45)';
|
||||||
|
ctx.lineWidth = 1.7;
|
||||||
|
ctx.shadowColor = '#2efbff';
|
||||||
|
ctx.shadowBlur = waveIsFresh ? 6 : 2;
|
||||||
|
|
||||||
|
const stepX = waveformPointCount > 1 ? (W / (waveformPointCount - 1)) : W;
|
||||||
|
ctx.beginPath();
|
||||||
|
const firstY = midY - (pagerScopeDisplayWave[0] * midY * 0.9);
|
||||||
|
ctx.moveTo(0, firstY);
|
||||||
|
for (let i = 1; i < waveformPointCount - 1; i++) {
|
||||||
|
const x = i * stepX;
|
||||||
|
const y = midY - (pagerScopeDisplayWave[i] * midY * 0.9);
|
||||||
|
const nx = (i + 1) * stepX;
|
||||||
|
const ny = midY - (pagerScopeDisplayWave[i + 1] * midY * 0.9);
|
||||||
|
const cx = (x + nx) / 2;
|
||||||
|
const cy = (y + ny) / 2;
|
||||||
|
ctx.quadraticCurveTo(x, y, cx, cy);
|
||||||
|
}
|
||||||
|
const lastX = (waveformPointCount - 1) * stepX;
|
||||||
|
const lastY = midY - (pagerScopeDisplayWave[waveformPointCount - 1] * midY * 0.9);
|
||||||
|
ctx.lineTo(lastX, lastY);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
if (!waveIsFresh) {
|
||||||
|
for (let i = 0; i < pagerScopeDisplayWave.length; i++) {
|
||||||
|
pagerScopeDisplayWave[i] *= SCOPE_WAVE_IDLE_DECAY;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
ctx.shadowBlur = 0;
|
ctx.shadowBlur = 0;
|
||||||
|
|
||||||
// Peak indicator (dashed red line)
|
// Peak indicator (dashed red line)
|
||||||
@@ -5331,11 +5607,15 @@
|
|||||||
if (rmsLabel) rmsLabel.textContent = Math.round(pagerScopeRms);
|
if (rmsLabel) rmsLabel.textContent = Math.round(pagerScopeRms);
|
||||||
if (peakLabel) peakLabel.textContent = Math.round(pagerScopePeak);
|
if (peakLabel) peakLabel.textContent = Math.round(pagerScopePeak);
|
||||||
if (statusLabel) {
|
if (statusLabel) {
|
||||||
if (pagerScopeRms > 500) {
|
const waveIsFresh = (performance.now() - pagerScopeLastWaveAt) < 700;
|
||||||
statusLabel.textContent = 'SIGNAL';
|
if (pagerScopeRms > 1300 && waveIsFresh) {
|
||||||
statusLabel.style.color = '#0f0';
|
statusLabel.textContent = 'DEMODULATING';
|
||||||
|
statusLabel.style.color = '#00ff88';
|
||||||
|
} else if (pagerScopeRms > 500) {
|
||||||
|
statusLabel.textContent = 'CARRIER';
|
||||||
|
statusLabel.style.color = '#2efbff';
|
||||||
} else {
|
} else {
|
||||||
statusLabel.textContent = 'MONITORING';
|
statusLabel.textContent = 'QUIET';
|
||||||
statusLabel.style.color = '#555';
|
statusLabel.style.color = '#555';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5349,6 +5629,11 @@
|
|||||||
pagerScopeAnim = null;
|
pagerScopeAnim = null;
|
||||||
}
|
}
|
||||||
pagerScopeCtx = null;
|
pagerScopeCtx = null;
|
||||||
|
pagerScopeWaveBuffer = [];
|
||||||
|
pagerScopeDisplayWave = [];
|
||||||
|
pagerScopeHistory = [];
|
||||||
|
pagerScopeLastWaveAt = 0;
|
||||||
|
pagerScopeLastInputSample = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
function startDecoding() {
|
function startDecoding() {
|
||||||
@@ -5578,8 +5863,7 @@
|
|||||||
} else if (payload.type === 'info') {
|
} else if (payload.type === 'info') {
|
||||||
showInfo(`[${data.agent_name}] ${payload.text}`);
|
showInfo(`[${data.agent_name}] ${payload.text}`);
|
||||||
} else if (payload.type === 'scope') {
|
} else if (payload.type === 'scope') {
|
||||||
pagerScopeTargetRms = payload.rms;
|
applyPagerScopeData(payload);
|
||||||
pagerScopeTargetPeak = payload.peak;
|
|
||||||
}
|
}
|
||||||
} else if (data.type === 'keepalive') {
|
} else if (data.type === 'keepalive') {
|
||||||
// Ignore keepalive messages
|
// Ignore keepalive messages
|
||||||
@@ -5599,8 +5883,7 @@
|
|||||||
} else if (data.type === 'raw') {
|
} else if (data.type === 'raw') {
|
||||||
showInfo(data.text);
|
showInfo(data.text);
|
||||||
} else if (data.type === 'scope') {
|
} else if (data.type === 'scope') {
|
||||||
pagerScopeTargetRms = data.rms;
|
applyPagerScopeData(data);
|
||||||
pagerScopeTargetPeak = data.peak;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en" class="{% if offline_settings.tile_provider in ['cartodb_dark', 'cartodb_dark_cyan'] %}map-cyber-enabled{% elif offline_settings.tile_provider == 'cartodb_dark_flir' %}map-flir-enabled{% endif %}">
|
<html lang="en" class="{% if offline_settings.tile_provider in ['cartodb_dark', 'cartodb_dark_cyan'] %}map-cyber-enabled{% endif %}">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
|||||||
@@ -73,7 +73,6 @@
|
|||||||
</div>
|
</div>
|
||||||
<select id="tileProvider" class="settings-select" onchange="Settings.setTileProvider(this.value)">
|
<select id="tileProvider" class="settings-select" onchange="Settings.setTileProvider(this.value)">
|
||||||
<option value="cartodb_dark_cyan">Intercept Default</option>
|
<option value="cartodb_dark_cyan">Intercept Default</option>
|
||||||
<option value="cartodb_dark_flir">FLIR Thermal</option>
|
|
||||||
<option value="cartodb_dark">CartoDB Dark</option>
|
<option value="cartodb_dark">CartoDB Dark</option>
|
||||||
<option value="openstreetmap">OpenStreetMap</option>
|
<option value="openstreetmap">OpenStreetMap</option>
|
||||||
<option value="cartodb_light">CartoDB Positron</option>
|
<option value="cartodb_light">CartoDB Positron</option>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en" class="{% if offline_settings.tile_provider in ['cartodb_dark', 'cartodb_dark_cyan'] %}map-cyber-enabled{% elif offline_settings.tile_provider == 'cartodb_dark_flir' %}map-flir-enabled{% endif %}">
|
<html lang="en" class="{% if offline_settings.tile_provider in ['cartodb_dark', 'cartodb_dark_cyan'] %}map-cyber-enabled{% endif %}">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
|||||||
47
tests/test_pager_scope.py
Normal file
47
tests/test_pager_scope.py
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
"""Tests for pager scope waveform payload generation."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
|
import queue
|
||||||
|
import struct
|
||||||
|
import threading
|
||||||
|
|
||||||
|
from routes.pager import _encode_scope_waveform, audio_relay_thread
|
||||||
|
|
||||||
|
|
||||||
|
def test_encode_scope_waveform_respects_window_and_range():
|
||||||
|
samples = (-32768, -16384, 0, 16384, 32767)
|
||||||
|
waveform = _encode_scope_waveform(samples, window_size=4)
|
||||||
|
|
||||||
|
assert len(waveform) == 4
|
||||||
|
assert waveform[0] == -64
|
||||||
|
assert waveform[1] == 0
|
||||||
|
assert waveform[2] == 64
|
||||||
|
assert waveform[3] == 127
|
||||||
|
assert max(waveform) <= 127
|
||||||
|
assert min(waveform) >= -127
|
||||||
|
|
||||||
|
|
||||||
|
def test_audio_relay_thread_emits_scope_waveform(monkeypatch):
|
||||||
|
base_samples = (0, 32767, -32768, 16384) * 512
|
||||||
|
pcm = struct.pack(f"<{len(base_samples)}h", *base_samples)
|
||||||
|
|
||||||
|
rtl_stdout = io.BytesIO(pcm)
|
||||||
|
multimon_stdin = io.BytesIO()
|
||||||
|
output_queue: queue.Queue = queue.Queue()
|
||||||
|
stop_event = threading.Event()
|
||||||
|
|
||||||
|
ticks = iter([0.0, 0.2, 0.2, 0.2])
|
||||||
|
monkeypatch.setattr("routes.pager.time.monotonic", lambda: next(ticks, 0.2))
|
||||||
|
|
||||||
|
audio_relay_thread(rtl_stdout, multimon_stdin, output_queue, stop_event)
|
||||||
|
|
||||||
|
scope_event = output_queue.get_nowait()
|
||||||
|
assert scope_event["type"] == "scope"
|
||||||
|
assert scope_event["rms"] > 0
|
||||||
|
assert scope_event["peak"] > 0
|
||||||
|
assert "waveform" in scope_event
|
||||||
|
assert len(scope_event["waveform"]) > 0
|
||||||
|
assert max(scope_event["waveform"]) <= 127
|
||||||
|
assert min(scope_event["waveform"]) >= -127
|
||||||
21
tests/test_sensor_scope.py
Normal file
21
tests/test_sensor_scope.py
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
"""Tests for synthesized 433 MHz scope waveform payload."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from routes.sensor import _build_scope_waveform
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_scope_waveform_has_expected_shape_and_bounds():
|
||||||
|
waveform = _build_scope_waveform(rssi=-8.5, snr=11.2, noise=-26.0, points=96)
|
||||||
|
|
||||||
|
assert len(waveform) == 96
|
||||||
|
assert max(waveform) <= 127
|
||||||
|
assert min(waveform) >= -127
|
||||||
|
assert any(sample != 0 for sample in waveform)
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_scope_waveform_changes_with_signal_profile():
|
||||||
|
low_snr = _build_scope_waveform(rssi=-14.0, snr=2.0, noise=-12.0, points=64)
|
||||||
|
high_snr = _build_scope_waveform(rssi=-14.0, snr=20.0, noise=-12.0, points=64)
|
||||||
|
|
||||||
|
assert low_snr != high_snr
|
||||||
25
tests/test_sstv_scope.py
Normal file
25
tests/test_sstv_scope.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
"""Tests for SSTV scope waveform encoding."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from utils.sstv.sstv_decoder import _encode_scope_waveform
|
||||||
|
|
||||||
|
|
||||||
|
def test_encode_scope_waveform_respects_window_and_bounds():
|
||||||
|
samples = np.array([-32768, -16384, 0, 16384, 32767], dtype=np.int16)
|
||||||
|
waveform = _encode_scope_waveform(samples, window_size=4)
|
||||||
|
|
||||||
|
assert len(waveform) == 4
|
||||||
|
assert waveform[0] == -64
|
||||||
|
assert waveform[1] == 0
|
||||||
|
assert waveform[2] == 64
|
||||||
|
assert waveform[3] == 127
|
||||||
|
assert max(waveform) <= 127
|
||||||
|
assert min(waveform) >= -127
|
||||||
|
|
||||||
|
|
||||||
|
def test_encode_scope_waveform_empty_input():
|
||||||
|
waveform = _encode_scope_waveform(np.array([], dtype=np.int16))
|
||||||
|
assert waveform == []
|
||||||
@@ -122,6 +122,17 @@ class DecodeProgress:
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _encode_scope_waveform(raw_samples: np.ndarray, window_size: int = 256) -> list[int]:
|
||||||
|
"""Compress recent int16 PCM samples to signed 8-bit values for SSE."""
|
||||||
|
if raw_samples.size == 0:
|
||||||
|
return []
|
||||||
|
|
||||||
|
window = raw_samples[-window_size:] if raw_samples.size > window_size else raw_samples
|
||||||
|
packed = np.rint(window.astype(np.float64) / 256.0).astype(np.int16)
|
||||||
|
packed = np.clip(packed, -127, 127)
|
||||||
|
return packed.tolist()
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# DopplerTracker
|
# DopplerTracker
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -423,6 +434,7 @@ class SSTVDecoder:
|
|||||||
# Scope: compute RMS/peak from raw int16 samples every chunk
|
# Scope: compute RMS/peak from raw int16 samples every chunk
|
||||||
rms_val = int(np.sqrt(np.mean(raw_samples.astype(np.float64) ** 2)))
|
rms_val = int(np.sqrt(np.mean(raw_samples.astype(np.float64) ** 2)))
|
||||||
peak_val = int(np.max(np.abs(raw_samples)))
|
peak_val = int(np.max(np.abs(raw_samples)))
|
||||||
|
waveform = _encode_scope_waveform(raw_samples)
|
||||||
|
|
||||||
if image_decoder is not None:
|
if image_decoder is not None:
|
||||||
# Currently decoding an image
|
# Currently decoding an image
|
||||||
@@ -451,7 +463,7 @@ class SSTVDecoder:
|
|||||||
message=f'Decoding {current_mode_name}: {pct}%',
|
message=f'Decoding {current_mode_name}: {pct}%',
|
||||||
partial_image=partial_url,
|
partial_image=partial_url,
|
||||||
))
|
))
|
||||||
self._emit_scope(rms_val, peak_val, 'decoding')
|
self._emit_scope(rms_val, peak_val, 'decoding', waveform)
|
||||||
|
|
||||||
if complete:
|
if complete:
|
||||||
# Save image
|
# Save image
|
||||||
@@ -529,7 +541,7 @@ class SSTVDecoder:
|
|||||||
vis_state=vis_detector.state.value,
|
vis_state=vis_detector.state.value,
|
||||||
))
|
))
|
||||||
|
|
||||||
self._emit_scope(rms_val, peak_val, scope_tone)
|
self._emit_scope(rms_val, peak_val, scope_tone, waveform)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error in decode thread: {e}")
|
logger.error(f"Error in decode thread: {e}")
|
||||||
@@ -762,11 +774,20 @@ class SSTVDecoder:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error in progress callback: {e}")
|
logger.error(f"Error in progress callback: {e}")
|
||||||
|
|
||||||
def _emit_scope(self, rms: int, peak: int, tone: str | None = None) -> None:
|
def _emit_scope(
|
||||||
|
self,
|
||||||
|
rms: int,
|
||||||
|
peak: int,
|
||||||
|
tone: str | None = None,
|
||||||
|
waveform: list[int] | None = None,
|
||||||
|
) -> None:
|
||||||
"""Emit scope signal levels to callback."""
|
"""Emit scope signal levels to callback."""
|
||||||
if self._callback:
|
if self._callback:
|
||||||
try:
|
try:
|
||||||
self._callback({'type': 'sstv_scope', 'rms': rms, 'peak': peak, 'tone': tone})
|
payload = {'type': 'sstv_scope', 'rms': rms, 'peak': peak, 'tone': tone}
|
||||||
|
if waveform:
|
||||||
|
payload['waveform'] = waveform
|
||||||
|
self._callback(payload)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user