Remove waterfall from all modes except listening post

Reverts IQ pipeline and removes syncWaterfallToFrequency calls from
pager, sensor, rtlamr, DMR, SSTV, and SSTV general modes. Waterfall
is now exclusive to listening post mode.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Smittix
2026-02-07 23:29:56 +00:00
parent f04ba7f143
commit beb38b6b98
13 changed files with 172 additions and 838 deletions
-18
View File
@@ -121,15 +121,6 @@ TOOL_DEPENDENCIES = {
'manual': 'https://github.com/EliasOenal/multimon-ng'
}
},
'rtl_sdr': {
'required': False,
'description': 'Raw IQ capture for live waterfall during decoding',
'install': {
'apt': 'sudo apt install rtl-sdr',
'brew': 'brew install librtlsdr',
'manual': 'https://osmocom.org/projects/rtl-sdr/wiki'
}
},
'rtl_test': {
'required': False,
'description': 'RTL-SDR device detection',
@@ -152,15 +143,6 @@ TOOL_DEPENDENCIES = {
'brew': 'brew install rtl_433',
'manual': 'https://github.com/merbanan/rtl_433'
}
},
'rtl_sdr': {
'required': False,
'description': 'Raw IQ capture for live waterfall during decoding',
'install': {
'apt': 'sudo apt install rtl-sdr',
'brew': 'brew install librtlsdr',
'manual': 'https://osmocom.org/projects/rtl-sdr/wiki'
}
}
}
},
-230
View File
@@ -1,230 +0,0 @@
"""IQ processing pipelines for live waterfall during SDR decoding.
Provides two pipeline functions:
- run_fm_iq_pipeline: FM demodulates IQ for pager decoding + FFT for waterfall
- run_passthrough_iq_pipeline: Passes raw IQ to rtl_433 + FFT for waterfall
"""
from __future__ import annotations
import logging
import struct
import threading
import queue
from datetime import datetime
from typing import IO, Optional
import numpy as np
logger = logging.getLogger('intercept.iq_processor')
# FFT parameters
FFT_SIZE = 2048
FFT_INTERVAL_SECONDS = 0.1 # ~10 updates/sec
def iq_to_complex(buf: bytes) -> np.ndarray:
"""Convert raw uint8 IQ bytes to complex float samples.
RTL-SDR outputs interleaved uint8 I/Q pairs centered at 127.5.
"""
raw = np.frombuffer(buf, dtype=np.uint8).astype(np.float32)
raw = (raw - 127.5) / 127.5
return raw[0::2] + 1j * raw[1::2]
def compute_fft_bins(samples: np.ndarray, fft_size: int = FFT_SIZE) -> list[float]:
"""Compute power spectral density in dB from complex IQ samples.
Returns a list of power values (dB) for each frequency bin.
"""
if len(samples) < fft_size:
# Pad with zeros if not enough samples
padded = np.zeros(fft_size, dtype=np.complex64)
padded[:len(samples)] = samples[:fft_size]
samples = padded
else:
samples = samples[:fft_size]
# Apply Hanning window to reduce spectral leakage
window = np.hanning(fft_size).astype(np.float32)
windowed = samples * window
# FFT and shift DC to center
spectrum = np.fft.fftshift(np.fft.fft(windowed))
# Power in dB (avoid log of zero)
power = np.abs(spectrum) ** 2
power = np.maximum(power, 1e-20)
power_db = 10.0 * np.log10(power)
return power_db.tolist()
def _push_waterfall(waterfall_queue: queue.Queue, bins: list[float],
center_freq_mhz: float, sample_rate: int) -> None:
"""Push a waterfall sweep message to the queue."""
half_span = (sample_rate / 1e6) / 2.0
msg = {
'type': 'waterfall_sweep',
'start_freq': center_freq_mhz - half_span,
'end_freq': center_freq_mhz + half_span,
'bins': bins,
'timestamp': datetime.now().isoformat(),
}
try:
waterfall_queue.put_nowait(msg)
except queue.Full:
# Drop oldest and retry
try:
waterfall_queue.get_nowait()
except queue.Empty:
pass
try:
waterfall_queue.put_nowait(msg)
except queue.Full:
pass
def run_fm_iq_pipeline(
iq_stdout: IO[bytes],
audio_stdin: IO[bytes],
waterfall_queue: queue.Queue,
center_freq_mhz: float,
sample_rate: int,
stop_event: threading.Event,
) -> None:
"""FM demodulation pipeline: IQ -> FFT + FM demod -> 22050 Hz PCM.
Reads raw uint8 IQ from rtl_sdr stdout, computes FFT for waterfall,
FM demodulates, decimates to 22050 Hz, and writes 16-bit PCM to
multimon-ng stdin.
Args:
iq_stdout: rtl_sdr stdout (raw uint8 IQ)
audio_stdin: multimon-ng stdin (16-bit PCM)
waterfall_queue: Queue for waterfall sweep messages
center_freq_mhz: Center frequency in MHz
sample_rate: IQ sample rate (should be 220500 for 10x decimation to 22050)
stop_event: Threading event to signal shutdown
"""
from scipy.signal import decimate as scipy_decimate
# Decimation factor: sample_rate / 22050
decim_factor = sample_rate // 22050
if decim_factor < 1:
decim_factor = 1
# Read in chunks: ~100ms worth of IQ data (2 bytes per sample: I + Q)
chunk_bytes = int(sample_rate * FFT_INTERVAL_SECONDS) * 2
# Align to even number of bytes (I/Q pairs)
chunk_bytes = (chunk_bytes // 2) * 2
# Previous sample for FM demod continuity
prev_sample = np.complex64(0)
logger.info(f"FM IQ pipeline started: {center_freq_mhz} MHz, "
f"sr={sample_rate}, decim={decim_factor}")
try:
while not stop_event.is_set():
raw = iq_stdout.read(chunk_bytes)
if not raw:
break
# Convert to complex IQ
iq = iq_to_complex(raw)
if len(iq) == 0:
continue
# Compute FFT for waterfall
bins = compute_fft_bins(iq, FFT_SIZE)
_push_waterfall(waterfall_queue, bins, center_freq_mhz, sample_rate)
# FM demodulation via instantaneous phase difference
# Prepend previous sample for continuity
iq_with_prev = np.concatenate(([prev_sample], iq))
prev_sample = iq[-1]
phase_diff = np.angle(iq_with_prev[1:] * np.conj(iq_with_prev[:-1]))
# Decimate to 22050 Hz
if decim_factor > 1:
audio = scipy_decimate(phase_diff, decim_factor, ftype='fir')
else:
audio = phase_diff
# Scale to 16-bit PCM range
audio = np.clip(audio * 10000, -32767, 32767).astype(np.int16)
# Write to multimon-ng
try:
audio_stdin.write(audio.tobytes())
audio_stdin.flush()
except (BrokenPipeError, OSError):
break
except Exception as e:
logger.error(f"FM IQ pipeline error: {e}")
finally:
logger.info("FM IQ pipeline stopped")
try:
audio_stdin.close()
except Exception:
pass
def run_passthrough_iq_pipeline(
iq_stdout: IO[bytes],
decoder_stdin: IO[bytes],
waterfall_queue: queue.Queue,
center_freq_mhz: float,
sample_rate: int,
stop_event: threading.Event,
) -> None:
"""Passthrough pipeline: IQ -> FFT + raw bytes to decoder.
Reads raw uint8 IQ from rtl_sdr stdout, computes FFT for waterfall,
and writes raw IQ bytes unchanged to rtl_433 stdin.
Args:
iq_stdout: rtl_sdr stdout (raw uint8 IQ)
decoder_stdin: rtl_433 stdin (raw cu8 IQ)
waterfall_queue: Queue for waterfall sweep messages
center_freq_mhz: Center frequency in MHz
sample_rate: IQ sample rate (should be 250000 for rtl_433)
stop_event: Threading event to signal shutdown
"""
# Read in chunks: ~100ms worth of IQ data
chunk_bytes = int(sample_rate * FFT_INTERVAL_SECONDS) * 2
chunk_bytes = (chunk_bytes // 2) * 2
logger.info(f"Passthrough IQ pipeline started: {center_freq_mhz} MHz, sr={sample_rate}")
try:
while not stop_event.is_set():
raw = iq_stdout.read(chunk_bytes)
if not raw:
break
# Compute FFT for waterfall
iq = iq_to_complex(raw)
if len(iq) > 0:
bins = compute_fft_bins(iq, FFT_SIZE)
_push_waterfall(waterfall_queue, bins, center_freq_mhz, sample_rate)
# Pass raw bytes unchanged to decoder
try:
decoder_stdin.write(raw)
decoder_stdin.flush()
except (BrokenPipeError, OSError):
break
except Exception as e:
logger.error(f"Passthrough IQ pipeline error: {e}")
finally:
logger.info("Passthrough IQ pipeline stopped")
try:
decoder_stdin.close()
except Exception:
pass
-30
View File
@@ -186,36 +186,6 @@ class CommandBuilder(ABC):
"""Return hardware capabilities for this SDR type."""
pass
def build_raw_capture_command(
self,
device: SDRDevice,
frequency_mhz: float,
sample_rate: int,
gain: Optional[float] = None,
ppm: Optional[int] = None,
bias_t: bool = False
) -> list[str]:
"""
Build raw IQ capture command (for IQ-based waterfall during decoding).
Args:
device: The SDR device to use
frequency_mhz: Center frequency in MHz
sample_rate: Sample rate in Hz
gain: Gain in dB (None for auto)
ppm: PPM frequency correction
bias_t: Enable bias-T power (for active antennas)
Returns:
Command as list of strings for subprocess
Raises:
NotImplementedError: If the SDR type does not support raw capture
"""
raise NotImplementedError(
f"Raw IQ capture not supported for {self.get_sdr_type().value}"
)
@classmethod
@abstractmethod
def get_sdr_type(cls) -> SDRType:
-37
View File
@@ -197,43 +197,6 @@ class RTLSDRCommandBuilder(CommandBuilder):
return cmd
def build_raw_capture_command(
self,
device: SDRDevice,
frequency_mhz: float,
sample_rate: int,
gain: Optional[float] = None,
ppm: Optional[int] = None,
bias_t: bool = False
) -> list[str]:
"""
Build rtl_sdr command for raw IQ capture.
Outputs raw uint8 IQ to stdout for processing by IQ pipelines.
"""
rtl_sdr_path = get_tool_path('rtl_sdr') or 'rtl_sdr'
freq_hz = int(frequency_mhz * 1e6)
cmd = [
rtl_sdr_path,
'-d', self._get_device_arg(device),
'-f', str(freq_hz),
'-s', str(sample_rate),
]
if gain is not None and gain > 0:
cmd.extend(['-g', str(gain)])
if ppm is not None and ppm != 0:
cmd.extend(['-p', str(ppm)])
if bias_t:
cmd.extend(['-T'])
# Output to stdout
cmd.append('-')
return cmd
def build_ais_command(
self,
device: SDRDevice,