feat: ship waterfall receiver overhaul and platform mode updates

This commit is contained in:
Smittix
2026-02-22 23:22:37 +00:00
parent 5d4b61b4c3
commit 5f480caa3f
41 changed files with 7635 additions and 3516 deletions

View File

@@ -0,0 +1,77 @@
/* INTERCEPT Per-Mode Cheat Sheets */
const CheatSheets = (function () {
'use strict';
const CONTENT = {
pager: { title: 'Pager Decoder', icon: '📟', hardware: 'RTL-SDR dongle', description: 'Decodes POCSAG and FLEX pager protocols via rtl_fm + multimon-ng.', whatToExpect: 'Numeric and alphanumeric pager messages with address codes.', tips: ['Try frequencies 152.240, 157.450, 462.9625 MHz', 'Gain 3845 dB works well for most dongles', 'POCSAG 512/1200/2400 baud are common'] },
sensor: { title: '433MHz Sensors', icon: '🌡️', hardware: 'RTL-SDR dongle', description: 'Decodes 433MHz IoT sensors via rtl_433.', whatToExpect: 'JSON events from weather stations, door sensors, car key fobs.', tips: ['Leave gain on AUTO', 'Walk around to discover hidden sensors', 'Protocol filter narrows false positives'] },
wifi: { title: 'WiFi Scanner', icon: '📡', hardware: 'WiFi adapter (monitor mode)', description: 'Scans WiFi networks and clients via airodump-ng or nmcli.', whatToExpect: 'SSIDs, BSSIDs, channel, signal strength, encryption type.', tips: ['Run airmon-ng check kill before monitoring', 'Proximity radar shows signal strength', 'TSCM baseline detects rogue APs'] },
bluetooth: { title: 'Bluetooth Scanner', icon: '🔵', hardware: 'Built-in or USB Bluetooth adapter', description: 'Scans BLE and classic Bluetooth devices. Identifies trackers.', whatToExpect: 'Device names, MACs, RSSI, manufacturer, tracker type.', tips: ['Proximity radar shows device distance', 'Known tracker DB has 47K+ fingerprints', 'Use BT Locate to physically find a tracker'] },
bt_locate: { title: 'BT Locate (SAR)', icon: '🎯', hardware: 'Bluetooth adapter + optional GPS', description: 'SAR Bluetooth locator. Tracks RSSI over time to triangulate position.', whatToExpect: 'RSSI chart, proximity band (IMMEDIATE/NEAR/FAR), GPS trail.', tips: ['Handoff from Bluetooth mode to lock onto a device', 'Indoor n=3.0 gives better distance estimates', 'Follow the heat trail toward stronger signal'] },
meshtastic: { title: 'Meshtastic', icon: '🕸️', hardware: 'Meshtastic LoRa node (USB)', description: 'Monitors Meshtastic LoRa mesh network messages and positions.', whatToExpect: 'Text messages, node map, telemetry.', tips: ['Default channel must match your mesh', 'Long-Fast has best range', 'GPS nodes appear on map automatically'] },
adsb: { title: 'ADS-B Aircraft', icon: '✈️', hardware: 'RTL-SDR + 1090MHz antenna', description: 'Tracks aircraft via ADS-B Mode S transponders using dump1090.', whatToExpect: 'Flight numbers, positions, altitude, speed, squawk codes.', tips: ['1090MHz — use a dedicated antenna', 'Emergency squawks: 7500 hijack, 7600 radio fail, 7700 emergency', 'Full Dashboard shows map view'] },
ais: { title: 'AIS Vessels', icon: '🚢', hardware: 'RTL-SDR + VHF antenna (162 MHz)', description: 'Tracks marine vessels via AIS using AIS-catcher.', whatToExpect: 'MMSI, vessel names, positions, speed, heading, cargo type.', tips: ['VHF antenna centered at 162MHz works best', 'DSC distress alerts appear in red', 'Coastline range ~40 nautical miles'] },
aprs: { title: 'APRS', icon: '📻', hardware: 'RTL-SDR + VHF + direwolf', description: 'Decodes APRS amateur packet radio via direwolf TNC modem.', whatToExpect: 'Station positions, weather reports, messages, telemetry.', tips: ['Primary APRS frequency: 144.390 MHz (North America)', 'direwolf must be running', 'Positions appear on the map'] },
satellite: { title: 'Satellite Tracker', icon: '🛰️', hardware: 'None (pass prediction only)', description: 'Predicts satellite pass times using TLE data from CelesTrak.', whatToExpect: 'Pass windows with AOS/LOS times, max elevation, bearing.', tips: ['Set observer location in Settings', 'Plan ISS SSTV using pass times', 'TLEs auto-update every 24 hours'] },
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'] },
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'] },
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'] },
};
function show(mode) {
const data = CONTENT[mode];
const modal = document.getElementById('cheatSheetModal');
const content = document.getElementById('cheatSheetContent');
if (!modal || !content) return;
if (!data) {
content.innerHTML = `<p style="color:var(--text-dim); font-family:var(--font-mono);">No cheat sheet for: ${mode}</p>`;
} else {
content.innerHTML = `
<div style="font-family:var(--font-mono, monospace);">
<div style="font-size:24px; margin-bottom:4px;">${data.icon}</div>
<h2 style="margin:0 0 8px; font-size:16px; color:var(--accent-cyan, #4aa3ff);">${data.title}</h2>
<div style="font-size:11px; color:var(--text-dim); margin-bottom:12px; border-bottom:1px solid rgba(255,255,255,0.08); padding-bottom:8px;">
Hardware: <span style="color:var(--text-secondary);">${data.hardware}</span>
</div>
<p style="font-size:12px; color:var(--text-secondary); margin:0 0 12px;">${data.description}</p>
<div style="margin-bottom:12px;">
<div style="font-size:10px; font-weight:700; text-transform:uppercase; letter-spacing:0.08em; color:var(--text-dim); margin-bottom:4px;">What to expect</div>
<p style="font-size:12px; color:var(--text-secondary); margin:0;">${data.whatToExpect}</p>
</div>
<div>
<div style="font-size:10px; font-weight:700; text-transform:uppercase; letter-spacing:0.08em; color:var(--text-dim); margin-bottom:6px;">Tips</div>
<ul style="margin:0; padding-left:16px; display:flex; flex-direction:column; gap:4px;">
${data.tips.map(t => `<li style="font-size:11px; color:var(--text-secondary);">${t}</li>`).join('')}
</ul>
</div>
</div>`;
}
modal.style.display = 'flex';
}
function hide() {
const modal = document.getElementById('cheatSheetModal');
if (modal) modal.style.display = 'none';
}
function showForCurrentMode() {
const mode = document.body.getAttribute('data-mode');
if (mode) show(mode);
}
return { show, hide, showForCurrentMode };
})();
window.CheatSheets = CheatSheets;

View File

@@ -25,7 +25,6 @@ const CommandPalette = (function() {
{ mode: 'gps', label: 'GPS' },
{ mode: 'meshtastic', label: 'Meshtastic' },
{ mode: 'websdr', label: 'WebSDR' },
{ mode: 'analytics', label: 'Analytics' },
{ mode: 'spaceweather', label: 'Space Weather' },
];

View File

@@ -139,7 +139,6 @@ const FirstRunSetup = (function() {
['sstv', 'ISS SSTV'],
['weathersat', 'Weather Sat'],
['sstv_general', 'HF SSTV'],
['analytics', 'Analytics'],
];
for (const [value, label] of modes) {
const opt = document.createElement('option');

View File

@@ -0,0 +1,74 @@
/* INTERCEPT Keyboard Shortcuts — global hotkey handler + help modal */
const KeyboardShortcuts = (function () {
'use strict';
const GUARD_SELECTOR = 'input, textarea, select, [contenteditable], .CodeMirror *';
let _handler = null;
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;
case 'c': e.preventDefault(); window.CheatSheets && CheatSheets.showForCurrentMode(); break;
default:
if (e.key >= '1' && e.key <= '9') {
e.preventDefault();
_switchToNthMode(parseInt(e.key) - 1);
}
}
} else if (!e.ctrlKey && !e.metaKey) {
if (e.key === '?') { showHelp(); }
if (e.key === 'Escape') {
const kbModal = document.getElementById('kbShortcutsModal');
if (kbModal && kbModal.style.display !== 'none') { hideHelp(); return; }
const csModal = document.getElementById('cheatSheetModal');
if (csModal && csModal.style.display !== 'none') {
window.CheatSheets && CheatSheets.hide(); return;
}
}
}
}
function _toggleSidebar() {
const mc = document.querySelector('.main-content');
if (mc) mc.classList.toggle('sidebar-collapsed');
}
function _switchToNthMode(n) {
if (!window.interceptModeCatalog) return;
const mode = document.body.getAttribute('data-mode');
if (!mode) return;
const catalog = window.interceptModeCatalog;
const entry = catalog[mode];
if (!entry) return;
const groupModes = Object.keys(catalog).filter(k => catalog[k].group === entry.group);
if (groupModes[n]) window.switchMode && switchMode(groupModes[n]);
}
function showHelp() {
const modal = document.getElementById('kbShortcutsModal');
if (modal) modal.style.display = 'flex';
}
function hideHelp() {
const modal = document.getElementById('kbShortcutsModal');
if (modal) modal.style.display = 'none';
}
function init() {
if (_handler) document.removeEventListener('keydown', _handler);
_handler = _handle;
document.addEventListener('keydown', _handler);
}
return { init, showHelp, hideHelp };
})();
window.KeyboardShortcuts = KeyboardShortcuts;

View File

@@ -114,13 +114,7 @@ const RecordingUI = (function() {
function openReplay(sessionId) {
if (!sessionId) return;
localStorage.setItem('analyticsReplaySession', sessionId);
if (typeof hideSettings === 'function') hideSettings();
if (typeof switchMode === 'function') {
switchMode('analytics', { updateUrl: true });
return;
}
window.location.href = '/?mode=analytics';
window.open(`/recordings/${sessionId}/download`, '_blank');
}
function escapeHtml(str) {

View File

@@ -1265,6 +1265,7 @@ function switchSettingsTab(tabName) {
} else if (tabName === 'location') {
loadObserverLocation();
} else if (tabName === 'alerts') {
loadVoiceAlertConfig();
if (typeof AlertCenter !== 'undefined') {
AlertCenter.loadFeed();
}
@@ -1277,6 +1278,61 @@ function switchSettingsTab(tabName) {
}
}
/**
* Load voice alert configuration into Settings > Alerts tab
*/
function loadVoiceAlertConfig() {
if (typeof VoiceAlerts === 'undefined') return;
const cfg = VoiceAlerts.getConfig();
const pager = document.getElementById('voiceCfgPager');
const tscm = document.getElementById('voiceCfgTscm');
const tracker = document.getElementById('voiceCfgTracker');
const squawk = document.getElementById('voiceCfgSquawk');
const rate = document.getElementById('voiceCfgRate');
const pitch = document.getElementById('voiceCfgPitch');
const rateVal = document.getElementById('voiceCfgRateVal');
const pitchVal = document.getElementById('voiceCfgPitchVal');
if (pager) pager.checked = cfg.streams.pager !== false;
if (tscm) tscm.checked = cfg.streams.tscm !== false;
if (tracker) tracker.checked = cfg.streams.bluetooth !== false;
if (squawk) squawk.checked = cfg.streams.squawks !== false;
if (rate) rate.value = cfg.rate;
if (pitch) pitch.value = cfg.pitch;
if (rateVal) rateVal.textContent = cfg.rate;
if (pitchVal) pitchVal.textContent = cfg.pitch;
// Populate voice dropdown
VoiceAlerts.getAvailableVoices().then(function (voices) {
var sel = document.getElementById('voiceCfgVoice');
if (!sel) return;
sel.innerHTML = '<option value="">Default</option>' +
voices.filter(function (v) { return v.lang.startsWith('en'); }).map(function (v) {
return '<option value="' + v.name + '"' + (v.name === cfg.voiceName ? ' selected' : '') + '>' + v.name + '</option>';
}).join('');
});
}
function saveVoiceAlertConfig() {
if (typeof VoiceAlerts === 'undefined') return;
VoiceAlerts.setConfig({
rate: parseFloat(document.getElementById('voiceCfgRate')?.value) || 1.1,
pitch: parseFloat(document.getElementById('voiceCfgPitch')?.value) || 0.9,
voiceName: document.getElementById('voiceCfgVoice')?.value || '',
streams: {
pager: !!document.getElementById('voiceCfgPager')?.checked,
tscm: !!document.getElementById('voiceCfgTscm')?.checked,
bluetooth: !!document.getElementById('voiceCfgTracker')?.checked,
squawks: !!document.getElementById('voiceCfgSquawk')?.checked,
},
});
}
function testVoiceAlert() {
if (typeof VoiceAlerts !== 'undefined') VoiceAlerts.testVoice();
}
/**
* Load API key status into the API Keys settings tab
*/

View File

@@ -0,0 +1,200 @@
/* 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');
}
// TSCM stream
if (_config.streams.tscm) {
_openStream('/tscm/sweep/stream', (ev) => {
try {
const d = JSON.parse(ev.data);
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;