mirror of
https://github.com/smittix/intercept.git
synced 2026-06-11 15:33:32 -07:00
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:
@@ -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'
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user