Remove legacy RF modes and add SignalID route/tests

This commit is contained in:
Smittix
2026-02-23 13:34:00 +00:00
parent 5f480caa3f
commit 7ea06caaa2
35 changed files with 3883 additions and 6920 deletions

View File

@@ -98,7 +98,7 @@ function switchMode(mode) {
const modeMap = {
'pager': 'pager', 'sensor': '433', 'aircraft': 'aircraft',
'satellite': 'satellite', 'wifi': 'wifi', 'bluetooth': 'bluetooth',
'listening': 'listening', 'meshtastic': 'meshtastic'
'meshtastic': 'meshtastic'
};
document.querySelectorAll('.mode-nav-btn').forEach(btn => {
const label = btn.querySelector('.nav-label');
@@ -114,7 +114,6 @@ function switchMode(mode) {
document.getElementById('satelliteMode').classList.toggle('active', mode === 'satellite');
document.getElementById('wifiMode').classList.toggle('active', mode === 'wifi');
document.getElementById('bluetoothMode').classList.toggle('active', mode === 'bluetooth');
document.getElementById('listeningPostMode').classList.toggle('active', mode === 'listening');
document.getElementById('aprsMode')?.classList.toggle('active', mode === 'aprs');
document.getElementById('tscmMode')?.classList.toggle('active', mode === 'tscm');
document.getElementById('rtlamrMode')?.classList.toggle('active', mode === 'rtlamr');
@@ -143,7 +142,6 @@ function switchMode(mode) {
'satellite': 'SATELLITE',
'wifi': 'WIFI',
'bluetooth': 'BLUETOOTH',
'listening': 'LISTENING POST',
'tscm': 'TSCM',
'aprs': 'APRS',
'meshtastic': 'MESHTASTIC'
@@ -166,7 +164,6 @@ function switchMode(mode) {
const showRadar = document.getElementById('adsbEnableMap')?.checked;
document.getElementById('aircraftVisuals').style.display = (mode === 'aircraft' && showRadar) ? 'grid' : 'none';
document.getElementById('satelliteVisuals').style.display = mode === 'satellite' ? 'block' : 'none';
document.getElementById('listeningPostVisuals').style.display = mode === 'listening' ? 'grid' : 'none';
// Update output panel title based on mode
const titles = {
@@ -176,7 +173,6 @@ function switchMode(mode) {
'satellite': 'Satellite Monitor',
'wifi': 'WiFi Scanner',
'bluetooth': 'Bluetooth Scanner',
'listening': 'Listening Post',
'meshtastic': 'Meshtastic Mesh Monitor'
};
document.getElementById('outputTitle').textContent = titles[mode] || 'Signal Monitor';
@@ -184,7 +180,7 @@ function switchMode(mode) {
// Show/hide Device Intelligence for modes that use it
const reconBtn = document.getElementById('reconBtn');
const intelBtn = document.querySelector('[onclick="exportDeviceDB()"]');
if (mode === 'satellite' || mode === 'aircraft' || mode === 'listening') {
if (mode === 'satellite' || mode === 'aircraft') {
document.getElementById('reconPanel').style.display = 'none';
if (reconBtn) reconBtn.style.display = 'none';
if (intelBtn) intelBtn.style.display = 'none';
@@ -198,7 +194,7 @@ function switchMode(mode) {
// Show RTL-SDR device section for modes that use it
document.getElementById('rtlDeviceSection').style.display =
(mode === 'pager' || mode === 'sensor' || mode === 'aircraft' || mode === 'listening') ? 'block' : 'none';
(mode === 'pager' || mode === 'sensor' || mode === 'aircraft') ? 'block' : 'none';
// Toggle mode-specific tool status displays
document.getElementById('toolStatusPager').style.display = (mode === 'pager') ? 'grid' : 'none';
@@ -207,7 +203,7 @@ function switchMode(mode) {
// Hide waterfall and output console for modes with their own visualizations
document.querySelector('.waterfall-container').style.display =
(mode === 'satellite' || mode === 'listening' || mode === 'aircraft' || mode === 'wifi' || mode === 'bluetooth' || mode === 'meshtastic' || mode === 'aprs' || mode === 'tscm' || mode === 'spystations') ? 'none' : 'block';
(mode === 'satellite' || mode === 'aircraft' || mode === 'wifi' || mode === 'bluetooth' || mode === 'meshtastic' || mode === 'aprs' || mode === 'tscm' || mode === 'spystations') ? 'none' : 'block';
document.getElementById('output').style.display =
(mode === 'satellite' || mode === 'aircraft' || mode === 'wifi' || mode === 'bluetooth' || mode === 'meshtastic' || mode === 'aprs' || mode === 'tscm' || mode === 'spystations') ? 'none' : 'block';
document.querySelector('.status-bar').style.display = (mode === 'satellite' || mode === 'tscm' || mode === 'meshtastic' || mode === 'aprs' || mode === 'spystations') ? 'none' : 'flex';
@@ -226,11 +222,6 @@ function switchMode(mode) {
} else if (mode === 'satellite') {
if (typeof initPolarPlot === 'function') initPolarPlot();
if (typeof initSatelliteList === 'function') initSatelliteList();
} else if (mode === 'listening') {
if (typeof checkScannerTools === 'function') checkScannerTools();
if (typeof checkAudioTools === 'function') checkAudioTools();
if (typeof populateScannerDeviceSelect === 'function') populateScannerDeviceSelect();
if (typeof populateAudioDeviceSelect === 'function') populateAudioDeviceSelect();
} else if (mode === 'meshtastic') {
if (typeof Meshtastic !== 'undefined' && Meshtastic.init) Meshtastic.init();
}

View File

@@ -16,17 +16,14 @@ const CheatSheets = (function () {
sstv: { title: 'ISS SSTV', icon: '🖼️', hardware: 'RTL-SDR + 145MHz antenna', description: 'Receives ISS SSTV images via slowrx.', whatToExpect: 'Color images during ISS SSTV events (PD180 mode).', tips: ['ISS SSTV: 145.800 MHz', 'Check ARISS for active event dates', 'ISS must be overhead — check pass times'] },
weathersat: { title: 'Weather Satellites', icon: '🌤️', hardware: 'RTL-SDR + 137MHz turnstile/QFH antenna', description: 'Decodes NOAA APT and Meteor LRPT weather imagery via SatDump.', whatToExpect: 'Infrared/visible cloud imagery.', tips: ['NOAA 15/18/19: 137.1137.9 MHz APT', 'Meteor M2-3: 137.9 MHz LRPT', 'Use circular polarized antenna (QFH or turnstile)'] },
sstv_general:{ title: 'HF SSTV', icon: '📷', hardware: 'RTL-SDR + HF upconverter', description: 'Receives HF SSTV transmissions.', whatToExpect: 'Amateur radio images on 14.230 MHz (USB mode).', tips: ['14.230 MHz USB is primary HF SSTV frequency', 'Scottie 1 and Martin 1 most common', 'Best during daylight hours'] },
gps: { title: 'GPS Receiver', icon: '🗺️', hardware: 'USB GPS receiver (NMEA)', description: 'Streams GPS position and feeds location to other modes.', whatToExpect: 'Lat/lon, altitude, speed, heading, satellite count.', tips: ['GPS feeds into RF Heatmap', 'BT Locate uses GPS for trail logging', 'Set observer location for satellite prediction'] },
gps: { title: 'GPS Receiver', icon: '🗺️', hardware: 'USB GPS receiver (NMEA)', description: 'Streams GPS position and feeds location to other modes.', whatToExpect: 'Lat/lon, altitude, speed, heading, satellite count.', tips: ['BT Locate uses GPS for trail logging', 'Set observer location for satellite prediction', 'Verify a 3D fix before relying on altitude'] },
spaceweather:{ title: 'Space Weather', icon: '☀️', hardware: 'None (NOAA/SpaceWeatherLive data)', description: 'Monitors solar activity and geomagnetic storm indices.', whatToExpect: 'Kp index, solar flux, X-ray flare alerts, CME tracking.', tips: ['High Kp (≥5) = geomagnetic storm', 'X-class flares cause HF radio blackouts', 'Check before HF or satellite operations'] },
listening: { title: 'Listening Post', icon: '🎧', hardware: 'RTL-SDR dongle', description: 'Wideband scanner and audio receiver for AM/FM/USB/LSB/CW.', whatToExpect: 'Audio from any frequency, spectrum waterfall, squelch.', tips: ['VHF air band: 118136 MHz AM', 'Marine VHF: 156174 MHz FM', 'HF requires upconverter or direct-sampling SDR'] },
tscm: { title: 'TSCM Counter-Surveillance', icon: '🔍', hardware: 'WiFi + Bluetooth adapters', description: 'Technical Surveillance Countermeasures — detects hidden devices.', whatToExpect: 'RF baseline comparison, rogue device alerts, tracker detection.', tips: ['Take baseline in a known-clean environment', 'New strong signals = potential bug', 'Correlate WiFi + Bluetooth observations'] },
spystations: { title: 'Spy Stations', icon: '🕵️', hardware: 'RTL-SDR + HF antenna', description: 'Database of known number stations, military, and diplomatic HF signals.', whatToExpect: 'Scheduled broadcasts, frequency database, tune-to links.', tips: ['Numbers stations often broadcast on the hour', 'Use Listening Post to tune directly', 'STANAG and HF mil signals are common'] },
tscm: { title: 'TSCM Counter-Surveillance', icon: '🔍', hardware: 'WiFi + Bluetooth adapters', description: 'Technical Surveillance Countermeasures — detects hidden devices.', whatToExpect: 'RF baseline comparison, rogue device alerts, tracker detection.', tips: ['Take baseline in a known-clean environment', 'New strong signals = potential bug', 'Correlate WiFi + Bluetooth observations'] },
spystations: { title: 'Spy Stations', icon: '🕵️', hardware: 'RTL-SDR + HF antenna', description: 'Database of known number stations, military, and diplomatic HF signals.', whatToExpect: 'Scheduled broadcasts, frequency database, tune-to links.', tips: ['Numbers stations often broadcast on the hour', 'Use Spectrum Waterfall to tune directly', 'STANAG and HF mil signals are common'] },
websdr: { title: 'WebSDR', icon: '🌐', hardware: 'None (uses remote SDR servers)', description: 'Access remote WebSDR receivers worldwide for HF shortwave listening.', whatToExpect: 'Live audio from global HF receivers, waterfall display.', tips: ['websdr.org lists available servers', 'Good for HF when local antenna is lacking', 'Use in-app player for seamless experience'] },
subghz: { title: 'SubGHz Transceiver', icon: '📡', hardware: 'HackRF One', description: 'Transmit and receive sub-GHz RF signals for IoT and industrial protocols.', whatToExpect: 'Raw signal capture, replay, and protocol analysis.', tips: ['Only use on licensed frequencies', 'Capture mode records raw IQ for replay', 'Common: garage doors, keyfobs, 315/433/868/915 MHz'] },
rtlamr: { title: 'Utility Meter Reader', icon: '⚡', hardware: 'RTL-SDR dongle', description: 'Reads AMI/AMR smart utility meter broadcasts via rtlamr.', whatToExpect: 'Meter IDs, consumption readings, interval data.', tips: ['Most meters broadcast on 915 MHz', 'MSG types 5, 7, 13, 21 most common', 'Consumption data is read-only public broadcast'] },
waterfall: { title: 'Spectrum Waterfall', icon: '🌊', hardware: 'RTL-SDR or HackRF (WebSocket)', description: 'Full-screen real-time FFT spectrum waterfall display.', whatToExpect: 'Color-coded signal intensity scrolling over time.', tips: ['Turbo palette has best contrast for weak signals', 'Peak hold shows max power in red', 'Hover over waterfall to see frequency'] },
rfheatmap: { title: 'RF Heatmap', icon: '🗺️', hardware: 'GPS receiver + WiFi/BT/SDR', description: 'GPS-tagged signal strength heatmap. Walk to build coverage maps.', whatToExpect: 'Leaflet map with heat overlay showing signal by location.', tips: ['Connect GPS first, wait for fix', 'Set min sample distance to avoid duplicates', 'Export GeoJSON for use in QGIS'] },
fingerprint: { title: 'RF Fingerprinting', icon: '🔬', hardware: 'RTL-SDR + Listening Post scanner', description: 'Records RF baselines and detects anomalies via statistical comparison.', whatToExpect: 'Band-by-band power comparison, z-score anomaly detection.', tips: ['Take baseline in a clean RF environment', 'Z-score ≥3 = statistically significant anomaly', 'New bands highlighted in purple'] },
waterfall: { title: 'Spectrum Waterfall', icon: '🌊', hardware: 'RTL-SDR or HackRF (WebSocket)', description: 'Full-screen real-time FFT spectrum waterfall display.', whatToExpect: 'Color-coded signal intensity scrolling over time.', tips: ['Turbo palette has best contrast for weak signals', 'Peak hold shows max power in red', 'Hover over waterfall to see frequency'] },
};
function show(mode) {

View File

@@ -12,8 +12,8 @@ const CommandPalette = (function() {
{ mode: 'pager', label: 'Pager' },
{ mode: 'sensor', label: '433MHz Sensors' },
{ mode: 'rtlamr', label: 'Meters' },
{ mode: 'listening', label: 'Listening Post' },
{ mode: 'subghz', label: 'SubGHz' },
{ mode: 'waterfall', label: 'Spectrum Waterfall' },
{ mode: 'aprs', label: 'APRS' },
{ mode: 'wifi', label: 'WiFi Scanner' },
{ mode: 'bluetooth', label: 'Bluetooth Scanner' },

View File

@@ -130,7 +130,7 @@ const FirstRunSetup = (function() {
['pager', 'Pager'],
['sensor', '433MHz'],
['rtlamr', 'Meters'],
['listening', 'Listening Post'],
['waterfall', 'Waterfall'],
['wifi', 'WiFi'],
['bluetooth', 'Bluetooth'],
['bt_locate', 'BT Locate'],
@@ -149,7 +149,11 @@ const FirstRunSetup = (function() {
const savedDefaultMode = localStorage.getItem(DEFAULT_MODE_KEY);
if (savedDefaultMode) {
modeSelectEl.value = savedDefaultMode;
const normalizedMode = savedDefaultMode === 'listening' ? 'waterfall' : savedDefaultMode;
modeSelectEl.value = normalizedMode;
if (normalizedMode !== savedDefaultMode) {
localStorage.setItem(DEFAULT_MODE_KEY, normalizedMode);
}
}
actionsEl.appendChild(modeSelectEl);

View File

@@ -8,14 +8,12 @@ const KeyboardShortcuts = (function () {
function _handle(e) {
if (e.target.matches(GUARD_SELECTOR)) return;
if (e.altKey) {
switch (e.key.toLowerCase()) {
case 'w': e.preventDefault(); window.switchMode && switchMode('waterfall'); break;
case 'h': e.preventDefault(); window.switchMode && switchMode('rfheatmap'); break;
case 'n': e.preventDefault(); window.switchMode && switchMode('fingerprint'); break;
case 'm': e.preventDefault(); window.VoiceAlerts && VoiceAlerts.toggleMute(); break;
case 's': e.preventDefault(); _toggleSidebar(); break;
case 'k': e.preventDefault(); showHelp(); break;
if (e.altKey) {
switch (e.key.toLowerCase()) {
case 'w': e.preventDefault(); window.switchMode && switchMode('waterfall'); break;
case 'm': e.preventDefault(); window.VoiceAlerts && VoiceAlerts.toggleMute(); break;
case 's': e.preventDefault(); _toggleSidebar(); break;
case 'k': e.preventDefault(); showHelp(); break;
case 'c': e.preventDefault(); window.CheatSheets && CheatSheets.showForCurrentMode(); break;
default:
if (e.key >= '1' && e.key <= '9') {

View File

@@ -1,113 +1,145 @@
/* INTERCEPT Voice Alerts — Web Speech API queue with priority system */
const VoiceAlerts = (function () {
'use strict';
const PRIORITY = { LOW: 0, MEDIUM: 1, HIGH: 2 };
let _enabled = true;
let _muted = false;
let _queue = [];
let _speaking = false;
let _sources = {};
const STORAGE_KEY = 'intercept-voice-muted';
const CONFIG_KEY = 'intercept-voice-config';
// Default config
let _config = {
rate: 1.1,
pitch: 0.9,
voiceName: '',
streams: { pager: true, tscm: true, bluetooth: true },
};
function _loadConfig() {
_muted = localStorage.getItem(STORAGE_KEY) === 'true';
try {
const stored = localStorage.getItem(CONFIG_KEY);
if (stored) {
const parsed = JSON.parse(stored);
_config.rate = parsed.rate ?? _config.rate;
_config.pitch = parsed.pitch ?? _config.pitch;
_config.voiceName = parsed.voiceName ?? _config.voiceName;
if (parsed.streams) {
Object.assign(_config.streams, parsed.streams);
}
}
} catch (_) {}
_updateMuteButton();
}
function _updateMuteButton() {
const btn = document.getElementById('voiceMuteBtn');
if (!btn) return;
btn.classList.toggle('voice-muted', _muted);
btn.title = _muted ? 'Unmute voice alerts' : 'Mute voice alerts';
btn.style.opacity = _muted ? '0.4' : '1';
}
function _getVoice() {
if (!_config.voiceName) return null;
const voices = window.speechSynthesis ? speechSynthesis.getVoices() : [];
return voices.find(v => v.name === _config.voiceName) || null;
}
function speak(text, priority) {
if (priority === undefined) priority = PRIORITY.MEDIUM;
if (!_enabled || _muted) return;
if (!window.speechSynthesis) return;
if (priority === PRIORITY.LOW && _speaking) return;
if (priority === PRIORITY.HIGH && _speaking) {
window.speechSynthesis.cancel();
_queue = [];
_speaking = false;
}
_queue.push({ text, priority });
if (!_speaking) _dequeue();
}
function _dequeue() {
if (_queue.length === 0) { _speaking = false; return; }
_speaking = true;
const item = _queue.shift();
const utt = new SpeechSynthesisUtterance(item.text);
utt.rate = _config.rate;
utt.pitch = _config.pitch;
const voice = _getVoice();
if (voice) utt.voice = voice;
utt.onend = () => { _speaking = false; _dequeue(); };
utt.onerror = () => { _speaking = false; _dequeue(); };
window.speechSynthesis.speak(utt);
}
function toggleMute() {
_muted = !_muted;
localStorage.setItem(STORAGE_KEY, _muted ? 'true' : 'false');
_updateMuteButton();
if (_muted && window.speechSynthesis) window.speechSynthesis.cancel();
}
function _openStream(url, handler, key) {
if (_sources[key]) return;
const es = new EventSource(url);
es.onmessage = handler;
es.onerror = () => { es.close(); delete _sources[key]; };
_sources[key] = es;
}
function _startStreams() {
if (!_enabled) return;
// Pager stream
if (_config.streams.pager) {
_openStream('/stream', (ev) => {
try {
const d = JSON.parse(ev.data);
if (d.address && d.message) {
speak(`Pager message to ${d.address}: ${String(d.message).slice(0, 60)}`, PRIORITY.MEDIUM);
}
} catch (_) {}
}, 'pager');
}
/* INTERCEPT Voice Alerts — Web Speech API queue with priority system */
const VoiceAlerts = (function () {
'use strict';
const PRIORITY = { LOW: 0, MEDIUM: 1, HIGH: 2 };
let _enabled = true;
let _muted = false;
let _queue = [];
let _speaking = false;
let _sources = {};
const STORAGE_KEY = 'intercept-voice-muted';
const CONFIG_KEY = 'intercept-voice-config';
const RATE_MIN = 0.5;
const RATE_MAX = 2.0;
const PITCH_MIN = 0.5;
const PITCH_MAX = 2.0;
// Default config
let _config = {
rate: 1.1,
pitch: 0.9,
voiceName: '',
streams: { pager: true, tscm: true, bluetooth: true },
};
function _toNumberInRange(value, fallback, min, max) {
const n = Number(value);
if (!Number.isFinite(n)) return fallback;
return Math.min(max, Math.max(min, n));
}
function _normalizeConfig() {
_config.rate = _toNumberInRange(_config.rate, 1.1, RATE_MIN, RATE_MAX);
_config.pitch = _toNumberInRange(_config.pitch, 0.9, PITCH_MIN, PITCH_MAX);
_config.voiceName = typeof _config.voiceName === 'string' ? _config.voiceName : '';
}
function _isSpeechSupported() {
return !!(window.speechSynthesis && typeof window.SpeechSynthesisUtterance !== 'undefined');
}
function _showVoiceToast(title, message, type) {
if (typeof window.showAppToast === 'function') {
window.showAppToast(title, message, type || 'warning');
}
}
function _loadConfig() {
_muted = localStorage.getItem(STORAGE_KEY) === 'true';
try {
const stored = localStorage.getItem(CONFIG_KEY);
if (stored) {
const parsed = JSON.parse(stored);
_config.rate = parsed.rate ?? _config.rate;
_config.pitch = parsed.pitch ?? _config.pitch;
_config.voiceName = parsed.voiceName ?? _config.voiceName;
if (parsed.streams) {
Object.assign(_config.streams, parsed.streams);
}
}
} catch (_) {}
_normalizeConfig();
_updateMuteButton();
}
function _updateMuteButton() {
const btn = document.getElementById('voiceMuteBtn');
if (!btn) return;
btn.classList.toggle('voice-muted', _muted);
btn.title = _muted ? 'Unmute voice alerts' : 'Mute voice alerts';
btn.style.opacity = _muted ? '0.4' : '1';
}
function _getVoice() {
if (!_config.voiceName) return null;
const voices = window.speechSynthesis ? speechSynthesis.getVoices() : [];
return voices.find(v => v.name === _config.voiceName) || null;
}
function _createUtterance(text) {
const utt = new SpeechSynthesisUtterance(text);
utt.rate = _toNumberInRange(_config.rate, 1.1, RATE_MIN, RATE_MAX);
utt.pitch = _toNumberInRange(_config.pitch, 0.9, PITCH_MIN, PITCH_MAX);
const voice = _getVoice();
if (voice) utt.voice = voice;
return utt;
}
function speak(text, priority) {
if (priority === undefined) priority = PRIORITY.MEDIUM;
if (!_enabled || _muted) return;
if (!window.speechSynthesis) return;
if (priority === PRIORITY.LOW && _speaking) return;
if (priority === PRIORITY.HIGH && _speaking) {
window.speechSynthesis.cancel();
_queue = [];
_speaking = false;
}
_queue.push({ text, priority });
if (!_speaking) _dequeue();
}
function _dequeue() {
if (_queue.length === 0) { _speaking = false; return; }
_speaking = true;
const item = _queue.shift();
const utt = _createUtterance(item.text);
utt.onend = () => { _speaking = false; _dequeue(); };
utt.onerror = () => { _speaking = false; _dequeue(); };
window.speechSynthesis.speak(utt);
}
function toggleMute() {
_muted = !_muted;
localStorage.setItem(STORAGE_KEY, _muted ? 'true' : 'false');
_updateMuteButton();
if (_muted && window.speechSynthesis) window.speechSynthesis.cancel();
}
function _openStream(url, handler, key) {
if (_sources[key]) return;
const es = new EventSource(url);
es.onmessage = handler;
es.onerror = () => { es.close(); delete _sources[key]; };
_sources[key] = es;
}
function _startStreams() {
if (!_enabled) return;
// Pager stream
if (_config.streams.pager) {
_openStream('/stream', (ev) => {
try {
const d = JSON.parse(ev.data);
if (d.address && d.message) {
speak(`Pager message to ${d.address}: ${String(d.message).slice(0, 60)}`, PRIORITY.MEDIUM);
}
} catch (_) {}
}, 'pager');
}
// TSCM stream
if (_config.streams.tscm) {
_openStream('/tscm/sweep/stream', (ev) => {
@@ -116,85 +148,108 @@ const VoiceAlerts = (function () {
if (d.threat_level && d.description) {
speak(`TSCM alert: ${d.threat_level}${d.description}`, PRIORITY.HIGH);
}
} catch (_) {}
}, 'tscm');
}
// Bluetooth stream — tracker detection only
if (_config.streams.bluetooth) {
_openStream('/api/bluetooth/stream', (ev) => {
try {
const d = JSON.parse(ev.data);
if (d.service_data && d.service_data.tracker_type) {
speak(`Tracker detected: ${d.service_data.tracker_type}`, PRIORITY.HIGH);
}
} catch (_) {}
}, 'bluetooth');
}
}
function _stopStreams() {
Object.values(_sources).forEach(es => { try { es.close(); } catch (_) {} });
_sources = {};
}
function init() {
_loadConfig();
_startStreams();
}
function setEnabled(val) {
_enabled = val;
if (!val) {
_stopStreams();
if (window.speechSynthesis) window.speechSynthesis.cancel();
} else {
_startStreams();
}
}
// ── Config API (used by Ops Center voice config panel) ─────────────
function getConfig() {
return JSON.parse(JSON.stringify(_config));
}
function setConfig(cfg) {
if (cfg.rate !== undefined) _config.rate = cfg.rate;
if (cfg.pitch !== undefined) _config.pitch = cfg.pitch;
if (cfg.voiceName !== undefined) _config.voiceName = cfg.voiceName;
if (cfg.streams) Object.assign(_config.streams, cfg.streams);
localStorage.setItem(CONFIG_KEY, JSON.stringify(_config));
// Restart streams to apply per-stream toggle changes
_stopStreams();
_startStreams();
}
function getAvailableVoices() {
return new Promise(resolve => {
if (!window.speechSynthesis) { resolve([]); return; }
let voices = speechSynthesis.getVoices();
if (voices.length > 0) { resolve(voices); return; }
speechSynthesis.onvoiceschanged = () => {
resolve(speechSynthesis.getVoices());
};
// Timeout fallback
setTimeout(() => resolve(speechSynthesis.getVoices()), 500);
});
}
function testVoice(text) {
if (!window.speechSynthesis) return;
const utt = new SpeechSynthesisUtterance(text || 'Voice alert test. All systems nominal.');
utt.rate = _config.rate;
utt.pitch = _config.pitch;
const voice = _getVoice();
if (voice) utt.voice = voice;
speechSynthesis.speak(utt);
}
return { init, speak, toggleMute, setEnabled, getConfig, setConfig, getAvailableVoices, testVoice, PRIORITY };
})();
window.VoiceAlerts = VoiceAlerts;
} catch (_) {}
}, 'tscm');
}
// Bluetooth stream — tracker detection only
if (_config.streams.bluetooth) {
_openStream('/api/bluetooth/stream', (ev) => {
try {
const d = JSON.parse(ev.data);
if (d.service_data && d.service_data.tracker_type) {
speak(`Tracker detected: ${d.service_data.tracker_type}`, PRIORITY.HIGH);
}
} catch (_) {}
}, 'bluetooth');
}
}
function _stopStreams() {
Object.values(_sources).forEach(es => { try { es.close(); } catch (_) {} });
_sources = {};
}
function init() {
_loadConfig();
if (_isSpeechSupported()) {
// Prime voices list early so user-triggered test calls are less likely to be silent.
speechSynthesis.getVoices();
}
_startStreams();
}
function setEnabled(val) {
_enabled = val;
if (!val) {
_stopStreams();
if (window.speechSynthesis) window.speechSynthesis.cancel();
} else {
_startStreams();
}
}
// ── Config API (used by Ops Center voice config panel) ─────────────
function getConfig() {
return JSON.parse(JSON.stringify(_config));
}
function setConfig(cfg) {
if (cfg.rate !== undefined) _config.rate = _toNumberInRange(cfg.rate, _config.rate, RATE_MIN, RATE_MAX);
if (cfg.pitch !== undefined) _config.pitch = _toNumberInRange(cfg.pitch, _config.pitch, PITCH_MIN, PITCH_MAX);
if (cfg.voiceName !== undefined) _config.voiceName = cfg.voiceName;
if (cfg.streams) Object.assign(_config.streams, cfg.streams);
_normalizeConfig();
localStorage.setItem(CONFIG_KEY, JSON.stringify(_config));
// Restart streams to apply per-stream toggle changes
_stopStreams();
_startStreams();
}
function getAvailableVoices() {
return new Promise(resolve => {
if (!window.speechSynthesis) { resolve([]); return; }
let voices = speechSynthesis.getVoices();
if (voices.length > 0) { resolve(voices); return; }
speechSynthesis.onvoiceschanged = () => {
resolve(speechSynthesis.getVoices());
};
// Timeout fallback
setTimeout(() => resolve(speechSynthesis.getVoices()), 500);
});
}
function testVoice(text) {
if (!_isSpeechSupported()) {
_showVoiceToast('Voice Unavailable', 'This browser does not support speech synthesis.', 'warning');
return;
}
// Make the test immediate and recover from a paused/stalled synthesis engine.
try {
speechSynthesis.getVoices();
if (speechSynthesis.paused) speechSynthesis.resume();
speechSynthesis.cancel();
} catch (_) {}
const utt = _createUtterance(text || 'Voice alert test. All systems nominal.');
let started = false;
utt.onstart = () => { started = true; };
utt.onerror = () => {
_showVoiceToast('Voice Test Failed', 'Speech synthesis failed to start. Check browser audio output.', 'warning');
};
speechSynthesis.speak(utt);
window.setTimeout(() => {
if (!started && !speechSynthesis.speaking && !speechSynthesis.pending) {
_showVoiceToast('No Voice Output', 'Test speech did not play. Verify browser audio and selected voice.', 'warning');
}
}, 1200);
}
return { init, speak, toggleMute, setEnabled, getConfig, setConfig, getAvailableVoices, testVoice, PRIORITY };
})();
window.VoiceAlerts = VoiceAlerts;