mirror of
https://github.com/smittix/intercept.git
synced 2026-04-24 06:40:00 -07:00
Allow listening with waterfall and speed up updates
This commit is contained in:
@@ -1305,6 +1305,11 @@ def start_audio() -> Response:
|
|||||||
scanner_config['device'] = device
|
scanner_config['device'] = device
|
||||||
scanner_config['sdr_type'] = sdr_type
|
scanner_config['sdr_type'] = sdr_type
|
||||||
|
|
||||||
|
# Stop waterfall if it's using the same SDR
|
||||||
|
if waterfall_running and waterfall_active_device == device:
|
||||||
|
_stop_waterfall_internal()
|
||||||
|
time.sleep(0.2)
|
||||||
|
|
||||||
# Claim device for listening audio
|
# Claim device for listening audio
|
||||||
if listening_active_device is None or listening_active_device != device:
|
if listening_active_device is None or listening_active_device != device:
|
||||||
if listening_active_device is not None:
|
if listening_active_device is not None:
|
||||||
@@ -1527,9 +1532,50 @@ waterfall_config = {
|
|||||||
'gain': 40,
|
'gain': 40,
|
||||||
'device': 0,
|
'device': 0,
|
||||||
'max_bins': 1024,
|
'max_bins': 1024,
|
||||||
|
'interval': 0.4,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_rtl_power_line(line: str) -> tuple[str | None, float | None, float | None, list[float]]:
|
||||||
|
"""Parse a single rtl_power CSV line into bins."""
|
||||||
|
if not line or line.startswith('#'):
|
||||||
|
return None, None, None, []
|
||||||
|
|
||||||
|
parts = [p.strip() for p in line.split(',')]
|
||||||
|
if len(parts) < 6:
|
||||||
|
return None, None, None, []
|
||||||
|
|
||||||
|
# Timestamp in first two fields (YYYY-MM-DD, HH:MM:SS)
|
||||||
|
timestamp = f"{parts[0]} {parts[1]}" if len(parts) >= 2 else parts[0]
|
||||||
|
|
||||||
|
start_idx = None
|
||||||
|
for i, tok in enumerate(parts):
|
||||||
|
try:
|
||||||
|
val = float(tok)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
if val > 1e5:
|
||||||
|
start_idx = i
|
||||||
|
break
|
||||||
|
if start_idx is None or len(parts) < start_idx + 4:
|
||||||
|
return timestamp, None, None, []
|
||||||
|
|
||||||
|
try:
|
||||||
|
seg_start = float(parts[start_idx])
|
||||||
|
seg_end = float(parts[start_idx + 1])
|
||||||
|
raw_values = []
|
||||||
|
for v in parts[start_idx + 3:]:
|
||||||
|
try:
|
||||||
|
raw_values.append(float(v))
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
if raw_values and raw_values[0] >= 0 and any(val < 0 for val in raw_values[1:]):
|
||||||
|
raw_values = raw_values[1:]
|
||||||
|
return timestamp, seg_start, seg_end, raw_values
|
||||||
|
except ValueError:
|
||||||
|
return timestamp, None, None, []
|
||||||
|
|
||||||
|
|
||||||
def _waterfall_loop():
|
def _waterfall_loop():
|
||||||
"""Continuous rtl_power sweep loop emitting waterfall data."""
|
"""Continuous rtl_power sweep loop emitting waterfall data."""
|
||||||
global waterfall_running, waterfall_process
|
global waterfall_running, waterfall_process
|
||||||
@@ -1540,87 +1586,59 @@ def _waterfall_loop():
|
|||||||
waterfall_running = False
|
waterfall_running = False
|
||||||
return
|
return
|
||||||
|
|
||||||
|
start_hz = int(waterfall_config['start_freq'] * 1e6)
|
||||||
|
end_hz = int(waterfall_config['end_freq'] * 1e6)
|
||||||
|
bin_hz = int(waterfall_config['bin_size'])
|
||||||
|
gain = waterfall_config['gain']
|
||||||
|
device = waterfall_config['device']
|
||||||
|
interval = float(waterfall_config.get('interval', 0.4))
|
||||||
|
|
||||||
|
cmd = [
|
||||||
|
rtl_power_path,
|
||||||
|
'-f', f'{start_hz}:{end_hz}:{bin_hz}',
|
||||||
|
'-i', str(interval),
|
||||||
|
'-g', str(gain),
|
||||||
|
'-d', str(device),
|
||||||
|
]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
while waterfall_running:
|
waterfall_process = subprocess.Popen(
|
||||||
start_hz = int(waterfall_config['start_freq'] * 1e6)
|
cmd,
|
||||||
end_hz = int(waterfall_config['end_freq'] * 1e6)
|
stdout=subprocess.PIPE,
|
||||||
bin_hz = int(waterfall_config['bin_size'])
|
stderr=subprocess.DEVNULL,
|
||||||
gain = waterfall_config['gain']
|
bufsize=1,
|
||||||
device = waterfall_config['device']
|
text=True,
|
||||||
|
)
|
||||||
|
|
||||||
cmd = [
|
current_ts = None
|
||||||
rtl_power_path,
|
all_bins: list[float] = []
|
||||||
'-f', f'{start_hz}:{end_hz}:{bin_hz}',
|
sweep_start_hz = start_hz
|
||||||
'-i', '0.5',
|
sweep_end_hz = end_hz
|
||||||
'-1',
|
|
||||||
'-g', str(gain),
|
|
||||||
'-d', str(device),
|
|
||||||
]
|
|
||||||
|
|
||||||
try:
|
if not waterfall_process.stdout:
|
||||||
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
|
return
|
||||||
waterfall_process = proc
|
|
||||||
stdout, _ = proc.communicate(timeout=15)
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
proc.kill()
|
|
||||||
stdout = b''
|
|
||||||
finally:
|
|
||||||
waterfall_process = None
|
|
||||||
|
|
||||||
|
for line in waterfall_process.stdout:
|
||||||
if not waterfall_running:
|
if not waterfall_running:
|
||||||
break
|
break
|
||||||
|
|
||||||
if not stdout:
|
ts, seg_start, seg_end, bins = _parse_rtl_power_line(line)
|
||||||
time.sleep(0.2)
|
if ts is None or not bins:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Parse rtl_power CSV output
|
if current_ts is None:
|
||||||
all_bins = []
|
current_ts = ts
|
||||||
sweep_start_hz = start_hz
|
|
||||||
sweep_end_hz = end_hz
|
|
||||||
|
|
||||||
for line in stdout.decode(errors='ignore').splitlines():
|
if ts != current_ts and all_bins:
|
||||||
if not line or line.startswith('#'):
|
|
||||||
continue
|
|
||||||
parts = [p.strip() for p in line.split(',')]
|
|
||||||
start_idx = None
|
|
||||||
for i, tok in enumerate(parts):
|
|
||||||
try:
|
|
||||||
val = float(tok)
|
|
||||||
except ValueError:
|
|
||||||
continue
|
|
||||||
if val > 1e5:
|
|
||||||
start_idx = i
|
|
||||||
break
|
|
||||||
if start_idx is None or len(parts) < start_idx + 4:
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
seg_start = float(parts[start_idx])
|
|
||||||
seg_end = float(parts[start_idx + 1])
|
|
||||||
seg_bin = float(parts[start_idx + 2])
|
|
||||||
raw_values = []
|
|
||||||
for v in parts[start_idx + 3:]:
|
|
||||||
try:
|
|
||||||
raw_values.append(float(v))
|
|
||||||
except ValueError:
|
|
||||||
continue
|
|
||||||
if raw_values and raw_values[0] >= 0 and any(val < 0 for val in raw_values[1:]):
|
|
||||||
raw_values = raw_values[1:]
|
|
||||||
all_bins.extend(raw_values)
|
|
||||||
sweep_start_hz = min(sweep_start_hz, seg_start)
|
|
||||||
sweep_end_hz = max(sweep_end_hz, seg_end)
|
|
||||||
except ValueError:
|
|
||||||
continue
|
|
||||||
|
|
||||||
if all_bins:
|
|
||||||
max_bins = int(waterfall_config.get('max_bins') or 0)
|
max_bins = int(waterfall_config.get('max_bins') or 0)
|
||||||
if max_bins > 0 and len(all_bins) > max_bins:
|
bins_to_send = all_bins
|
||||||
all_bins = _downsample_bins(all_bins, max_bins)
|
if max_bins > 0 and len(bins_to_send) > max_bins:
|
||||||
|
bins_to_send = _downsample_bins(bins_to_send, max_bins)
|
||||||
msg = {
|
msg = {
|
||||||
'type': 'waterfall_sweep',
|
'type': 'waterfall_sweep',
|
||||||
'start_freq': sweep_start_hz / 1e6,
|
'start_freq': sweep_start_hz / 1e6,
|
||||||
'end_freq': sweep_end_hz / 1e6,
|
'end_freq': sweep_end_hz / 1e6,
|
||||||
'bins': all_bins,
|
'bins': bins_to_send,
|
||||||
'timestamp': datetime.now().isoformat(),
|
'timestamp': datetime.now().isoformat(),
|
||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
@@ -1635,15 +1653,73 @@ def _waterfall_loop():
|
|||||||
except queue.Full:
|
except queue.Full:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
time.sleep(0.1)
|
all_bins = []
|
||||||
|
sweep_start_hz = start_hz
|
||||||
|
sweep_end_hz = end_hz
|
||||||
|
current_ts = ts
|
||||||
|
|
||||||
|
all_bins.extend(bins)
|
||||||
|
if seg_start is not None:
|
||||||
|
sweep_start_hz = min(sweep_start_hz, seg_start)
|
||||||
|
if seg_end is not None:
|
||||||
|
sweep_end_hz = max(sweep_end_hz, seg_end)
|
||||||
|
|
||||||
|
# Flush any remaining bins
|
||||||
|
if all_bins and waterfall_running:
|
||||||
|
max_bins = int(waterfall_config.get('max_bins') or 0)
|
||||||
|
bins_to_send = all_bins
|
||||||
|
if max_bins > 0 and len(bins_to_send) > max_bins:
|
||||||
|
bins_to_send = _downsample_bins(bins_to_send, max_bins)
|
||||||
|
msg = {
|
||||||
|
'type': 'waterfall_sweep',
|
||||||
|
'start_freq': sweep_start_hz / 1e6,
|
||||||
|
'end_freq': sweep_end_hz / 1e6,
|
||||||
|
'bins': bins_to_send,
|
||||||
|
'timestamp': datetime.now().isoformat(),
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
waterfall_queue.put_nowait(msg)
|
||||||
|
except queue.Full:
|
||||||
|
pass
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Waterfall loop error: {e}")
|
logger.error(f"Waterfall loop error: {e}")
|
||||||
finally:
|
finally:
|
||||||
waterfall_running = False
|
waterfall_running = False
|
||||||
|
if waterfall_process and waterfall_process.poll() is None:
|
||||||
|
try:
|
||||||
|
waterfall_process.terminate()
|
||||||
|
waterfall_process.wait(timeout=1)
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
waterfall_process.kill()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
waterfall_process = None
|
||||||
logger.info("Waterfall loop stopped")
|
logger.info("Waterfall loop stopped")
|
||||||
|
|
||||||
|
|
||||||
|
def _stop_waterfall_internal() -> None:
|
||||||
|
"""Stop the waterfall display and release resources."""
|
||||||
|
global waterfall_running, waterfall_process, waterfall_active_device
|
||||||
|
|
||||||
|
waterfall_running = False
|
||||||
|
if waterfall_process and waterfall_process.poll() is None:
|
||||||
|
try:
|
||||||
|
waterfall_process.terminate()
|
||||||
|
waterfall_process.wait(timeout=1)
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
waterfall_process.kill()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
waterfall_process = None
|
||||||
|
|
||||||
|
if waterfall_active_device is not None:
|
||||||
|
app_module.release_sdr_device(waterfall_active_device)
|
||||||
|
waterfall_active_device = None
|
||||||
|
|
||||||
|
|
||||||
@listening_post_bp.route('/waterfall/start', methods=['POST'])
|
@listening_post_bp.route('/waterfall/start', methods=['POST'])
|
||||||
def start_waterfall() -> Response:
|
def start_waterfall() -> Response:
|
||||||
"""Start the waterfall/spectrogram display."""
|
"""Start the waterfall/spectrogram display."""
|
||||||
@@ -1664,6 +1740,11 @@ def start_waterfall() -> Response:
|
|||||||
waterfall_config['bin_size'] = int(data.get('bin_size', 10000))
|
waterfall_config['bin_size'] = int(data.get('bin_size', 10000))
|
||||||
waterfall_config['gain'] = int(data.get('gain', 40))
|
waterfall_config['gain'] = int(data.get('gain', 40))
|
||||||
waterfall_config['device'] = int(data.get('device', 0))
|
waterfall_config['device'] = int(data.get('device', 0))
|
||||||
|
if data.get('interval') is not None:
|
||||||
|
interval = float(data.get('interval', waterfall_config['interval']))
|
||||||
|
if interval < 0.1 or interval > 5:
|
||||||
|
return jsonify({'status': 'error', 'message': 'interval must be between 0.1 and 5 seconds'}), 400
|
||||||
|
waterfall_config['interval'] = interval
|
||||||
if data.get('max_bins') is not None:
|
if data.get('max_bins') is not None:
|
||||||
max_bins = int(data.get('max_bins', waterfall_config['max_bins']))
|
max_bins = int(data.get('max_bins', waterfall_config['max_bins']))
|
||||||
if max_bins < 64 or max_bins > 4096:
|
if max_bins < 64 or max_bins > 4096:
|
||||||
@@ -1698,23 +1779,7 @@ def start_waterfall() -> Response:
|
|||||||
@listening_post_bp.route('/waterfall/stop', methods=['POST'])
|
@listening_post_bp.route('/waterfall/stop', methods=['POST'])
|
||||||
def stop_waterfall() -> Response:
|
def stop_waterfall() -> Response:
|
||||||
"""Stop the waterfall display."""
|
"""Stop the waterfall display."""
|
||||||
global waterfall_running, waterfall_process, waterfall_active_device
|
_stop_waterfall_internal()
|
||||||
|
|
||||||
waterfall_running = False
|
|
||||||
if waterfall_process and waterfall_process.poll() is None:
|
|
||||||
try:
|
|
||||||
waterfall_process.terminate()
|
|
||||||
waterfall_process.wait(timeout=1)
|
|
||||||
except Exception:
|
|
||||||
try:
|
|
||||||
waterfall_process.kill()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
waterfall_process = None
|
|
||||||
|
|
||||||
if waterfall_active_device is not None:
|
|
||||||
app_module.release_sdr_device(waterfall_active_device)
|
|
||||||
waterfall_active_device = None
|
|
||||||
|
|
||||||
return jsonify({'status': 'stopped'})
|
return jsonify({'status': 'stopped'})
|
||||||
|
|
||||||
|
|||||||
@@ -2248,6 +2248,11 @@ async function _startDirectListenInternal() {
|
|||||||
await stopScanner();
|
await stopScanner();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isWaterfallRunning && waterfallMode === 'rf') {
|
||||||
|
resumeRfWaterfallAfterListening = true;
|
||||||
|
stopWaterfall();
|
||||||
|
}
|
||||||
|
|
||||||
const freqInput = document.getElementById('radioScanStart');
|
const freqInput = document.getElementById('radioScanStart');
|
||||||
const freq = freqInput ? parseFloat(freqInput.value) : 118.0;
|
const freq = freqInput ? parseFloat(freqInput.value) : 118.0;
|
||||||
const squelchValue = parseInt(document.getElementById('radioSquelchValue')?.textContent);
|
const squelchValue = parseInt(document.getElementById('radioSquelchValue')?.textContent);
|
||||||
@@ -2306,6 +2311,10 @@ async function _startDirectListenInternal() {
|
|||||||
addScannerLogEntry('Failed: ' + (result.message || 'Unknown error'), '', 'error');
|
addScannerLogEntry('Failed: ' + (result.message || 'Unknown error'), '', 'error');
|
||||||
isDirectListening = false;
|
isDirectListening = false;
|
||||||
updateDirectListenUI(false);
|
updateDirectListenUI(false);
|
||||||
|
if (resumeRfWaterfallAfterListening) {
|
||||||
|
resumeRfWaterfallAfterListening = false;
|
||||||
|
setTimeout(() => startWaterfall(), 200);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2352,6 +2361,15 @@ async function _startDirectListenInternal() {
|
|||||||
initAudioVisualizer();
|
initAudioVisualizer();
|
||||||
|
|
||||||
isDirectListening = true;
|
isDirectListening = true;
|
||||||
|
|
||||||
|
if (resumeRfWaterfallAfterListening) {
|
||||||
|
isWaterfallRunning = true;
|
||||||
|
const waterfallPanel = document.getElementById('waterfallPanel');
|
||||||
|
if (waterfallPanel) waterfallPanel.style.display = 'block';
|
||||||
|
document.getElementById('startWaterfallBtn').style.display = 'none';
|
||||||
|
document.getElementById('stopWaterfallBtn').style.display = 'block';
|
||||||
|
startAudioWaterfall();
|
||||||
|
}
|
||||||
updateDirectListenUI(true, freq);
|
updateDirectListenUI(true, freq);
|
||||||
addScannerLogEntry(`${freq.toFixed(3)} MHz (${currentModulation.toUpperCase()})`, '', 'signal');
|
addScannerLogEntry(`${freq.toFixed(3)} MHz (${currentModulation.toUpperCase()})`, '', 'signal');
|
||||||
|
|
||||||
@@ -2360,6 +2378,10 @@ async function _startDirectListenInternal() {
|
|||||||
addScannerLogEntry('Error: ' + e.message, '', 'error');
|
addScannerLogEntry('Error: ' + e.message, '', 'error');
|
||||||
isDirectListening = false;
|
isDirectListening = false;
|
||||||
updateDirectListenUI(false);
|
updateDirectListenUI(false);
|
||||||
|
if (resumeRfWaterfallAfterListening) {
|
||||||
|
resumeRfWaterfallAfterListening = false;
|
||||||
|
setTimeout(() => startWaterfall(), 200);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
isRestarting = false;
|
isRestarting = false;
|
||||||
}
|
}
|
||||||
@@ -2556,6 +2578,20 @@ function stopDirectListen() {
|
|||||||
currentSignalLevel = 0;
|
currentSignalLevel = 0;
|
||||||
updateDirectListenUI(false);
|
updateDirectListenUI(false);
|
||||||
addScannerLogEntry('Listening stopped');
|
addScannerLogEntry('Listening stopped');
|
||||||
|
|
||||||
|
if (waterfallMode === 'audio') {
|
||||||
|
stopAudioWaterfall();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resumeRfWaterfallAfterListening) {
|
||||||
|
resumeRfWaterfallAfterListening = false;
|
||||||
|
isWaterfallRunning = false;
|
||||||
|
setTimeout(() => startWaterfall(), 200);
|
||||||
|
} else if (waterfallMode === 'audio' && isWaterfallRunning) {
|
||||||
|
isWaterfallRunning = false;
|
||||||
|
document.getElementById('startWaterfallBtn').style.display = 'block';
|
||||||
|
document.getElementById('stopWaterfallBtn').style.display = 'none';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -3027,6 +3063,10 @@ let lastWaterfallDraw = 0;
|
|||||||
const WATERFALL_MIN_INTERVAL_MS = 50;
|
const WATERFALL_MIN_INTERVAL_MS = 50;
|
||||||
let waterfallInteractionBound = false;
|
let waterfallInteractionBound = false;
|
||||||
let waterfallResizeObserver = null;
|
let waterfallResizeObserver = null;
|
||||||
|
let waterfallMode = 'rf';
|
||||||
|
let audioWaterfallAnimId = null;
|
||||||
|
let lastAudioWaterfallDraw = 0;
|
||||||
|
let resumeRfWaterfallAfterListening = false;
|
||||||
|
|
||||||
function resizeCanvasToDisplaySize(canvas) {
|
function resizeCanvasToDisplaySize(canvas) {
|
||||||
if (!canvas) return false;
|
if (!canvas) return false;
|
||||||
@@ -3097,6 +3137,57 @@ function initWaterfallCanvas() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setWaterfallMode(mode) {
|
||||||
|
waterfallMode = mode;
|
||||||
|
const header = document.getElementById('waterfallFreqRange');
|
||||||
|
if (!header) return;
|
||||||
|
if (mode === 'audio') {
|
||||||
|
header.textContent = 'Audio Spectrum (0 - 22 kHz)';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startAudioWaterfall() {
|
||||||
|
if (audioWaterfallAnimId) return;
|
||||||
|
if (!visualizerAnalyser) {
|
||||||
|
initAudioVisualizer();
|
||||||
|
}
|
||||||
|
if (!visualizerAnalyser) return;
|
||||||
|
|
||||||
|
setWaterfallMode('audio');
|
||||||
|
initWaterfallCanvas();
|
||||||
|
|
||||||
|
const sampleRate = visualizerContext ? visualizerContext.sampleRate : 44100;
|
||||||
|
const maxFreqKhz = (sampleRate / 2) / 1000;
|
||||||
|
const dataArray = new Uint8Array(visualizerAnalyser.frequencyBinCount);
|
||||||
|
|
||||||
|
const drawFrame = (ts) => {
|
||||||
|
if (!isDirectListening || waterfallMode !== 'audio') {
|
||||||
|
stopAudioWaterfall();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (ts - lastAudioWaterfallDraw >= WATERFALL_MIN_INTERVAL_MS) {
|
||||||
|
lastAudioWaterfallDraw = ts;
|
||||||
|
visualizerAnalyser.getByteFrequencyData(dataArray);
|
||||||
|
const bins = Array.from(dataArray, v => v);
|
||||||
|
drawWaterfallRow(bins);
|
||||||
|
drawSpectrumLine(bins, 0, maxFreqKhz, 'kHz');
|
||||||
|
}
|
||||||
|
audioWaterfallAnimId = requestAnimationFrame(drawFrame);
|
||||||
|
};
|
||||||
|
|
||||||
|
audioWaterfallAnimId = requestAnimationFrame(drawFrame);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopAudioWaterfall() {
|
||||||
|
if (audioWaterfallAnimId) {
|
||||||
|
cancelAnimationFrame(audioWaterfallAnimId);
|
||||||
|
audioWaterfallAnimId = null;
|
||||||
|
}
|
||||||
|
if (waterfallMode === 'audio') {
|
||||||
|
waterfallMode = 'rf';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function dBmToRgb(normalized) {
|
function dBmToRgb(normalized) {
|
||||||
// Viridis-inspired: dark blue -> cyan -> green -> yellow
|
// Viridis-inspired: dark blue -> cyan -> green -> yellow
|
||||||
const n = Math.max(0, Math.min(1, normalized));
|
const n = Math.max(0, Math.min(1, normalized));
|
||||||
@@ -3176,7 +3267,7 @@ function drawWaterfallRow(bins) {
|
|||||||
waterfallCtx.putImageData(waterfallRowImage, 0, 0);
|
waterfallCtx.putImageData(waterfallRowImage, 0, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawSpectrumLine(bins, startFreq, endFreq) {
|
function drawSpectrumLine(bins, startFreq, endFreq, labelUnit) {
|
||||||
if (!spectrumCtx || !spectrumCanvas) return;
|
if (!spectrumCtx || !spectrumCanvas) return;
|
||||||
const w = spectrumCanvas.width;
|
const w = spectrumCanvas.width;
|
||||||
const h = spectrumCanvas.height;
|
const h = spectrumCanvas.height;
|
||||||
@@ -3206,7 +3297,8 @@ function drawSpectrumLine(bins, startFreq, endFreq) {
|
|||||||
for (let i = 0; i <= 4; i++) {
|
for (let i = 0; i <= 4; i++) {
|
||||||
const freq = startFreq + (freqRange / 4) * i;
|
const freq = startFreq + (freqRange / 4) * i;
|
||||||
const x = (w / 4) * i;
|
const x = (w / 4) * i;
|
||||||
spectrumCtx.fillText(freq.toFixed(1), x + 2, h - 2);
|
const label = labelUnit === 'kHz' ? freq.toFixed(0) : freq.toFixed(1);
|
||||||
|
spectrumCtx.fillText(label, x + 2, h - 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (bins.length === 0) return;
|
if (bins.length === 0) return;
|
||||||
@@ -3263,6 +3355,16 @@ function startWaterfall() {
|
|||||||
rangeLabel.textContent = `${startFreq.toFixed(1)} - ${endFreq.toFixed(1)} MHz`;
|
rangeLabel.textContent = `${startFreq.toFixed(1)} - ${endFreq.toFixed(1)} MHz`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isDirectListening) {
|
||||||
|
isWaterfallRunning = true;
|
||||||
|
const waterfallPanel = document.getElementById('waterfallPanel');
|
||||||
|
if (waterfallPanel) waterfallPanel.style.display = 'block';
|
||||||
|
document.getElementById('startWaterfallBtn').style.display = 'none';
|
||||||
|
document.getElementById('stopWaterfallBtn').style.display = 'block';
|
||||||
|
startAudioWaterfall();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
fetch('/listening/waterfall/start', {
|
fetch('/listening/waterfall/start', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@@ -3273,6 +3375,7 @@ function startWaterfall() {
|
|||||||
gain: gain,
|
gain: gain,
|
||||||
device: device,
|
device: device,
|
||||||
max_bins: maxBins,
|
max_bins: maxBins,
|
||||||
|
interval: 0.4,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
@@ -3294,6 +3397,14 @@ function startWaterfall() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function stopWaterfall() {
|
function stopWaterfall() {
|
||||||
|
if (waterfallMode === 'audio') {
|
||||||
|
stopAudioWaterfall();
|
||||||
|
isWaterfallRunning = false;
|
||||||
|
document.getElementById('startWaterfallBtn').style.display = 'block';
|
||||||
|
document.getElementById('stopWaterfallBtn').style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
fetch('/listening/waterfall/stop', { method: 'POST' })
|
fetch('/listening/waterfall/stop', { method: 'POST' })
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then(() => {
|
.then(() => {
|
||||||
@@ -3308,6 +3419,7 @@ function stopWaterfall() {
|
|||||||
function connectWaterfallSSE() {
|
function connectWaterfallSSE() {
|
||||||
if (waterfallEventSource) waterfallEventSource.close();
|
if (waterfallEventSource) waterfallEventSource.close();
|
||||||
waterfallEventSource = new EventSource('/listening/waterfall/stream');
|
waterfallEventSource = new EventSource('/listening/waterfall/stream');
|
||||||
|
waterfallMode = 'rf';
|
||||||
|
|
||||||
waterfallEventSource.onmessage = function(event) {
|
waterfallEventSource.onmessage = function(event) {
|
||||||
const msg = JSON.parse(event.data);
|
const msg = JSON.parse(event.data);
|
||||||
@@ -3335,6 +3447,9 @@ function connectWaterfallSSE() {
|
|||||||
|
|
||||||
function bindWaterfallInteraction() {
|
function bindWaterfallInteraction() {
|
||||||
const handler = (event) => {
|
const handler = (event) => {
|
||||||
|
if (waterfallMode === 'audio') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const canvas = event.currentTarget;
|
const canvas = event.currentTarget;
|
||||||
const rect = canvas.getBoundingClientRect();
|
const rect = canvas.getBoundingClientRect();
|
||||||
const x = event.clientX - rect.left;
|
const x = event.clientX - rect.left;
|
||||||
|
|||||||
Reference in New Issue
Block a user