Add real-time WebSocket waterfall with I/Q capture and server-side FFT

Replace the batch rtl_power SSE pipeline with continuous I/Q streaming
via WebSocket for smooth ~25fps waterfall display. The server captures
raw I/Q samples (rtl_sdr/rx_sdr), computes Hann-windowed FFT, and
sends compact binary frames (1035 bytes vs ~15KB JSON, 93% reduction).
Client falls back to existing SSE path if WebSocket is unavailable.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Smittix
2026-02-08 12:37:50 +00:00
parent 7aae2944d4
commit 026337a350
12 changed files with 1107 additions and 8 deletions

View File

@@ -185,6 +185,43 @@ class AirspyCommandBuilder(CommandBuilder):
return cmd
def build_iq_capture_command(
self,
device: SDRDevice,
frequency_mhz: float,
sample_rate: int = 2048000,
gain: Optional[float] = None,
ppm: Optional[int] = None,
bias_t: bool = False,
output_format: str = 'cu8',
) -> list[str]:
"""
Build rx_sdr command for raw I/Q capture with Airspy.
Outputs unsigned 8-bit I/Q pairs to stdout for waterfall display.
"""
device_str = self._build_device_string(device)
freq_hz = int(frequency_mhz * 1e6)
cmd = [
'rx_sdr',
'-d', device_str,
'-f', str(freq_hz),
'-s', str(sample_rate),
'-F', 'CU8',
]
if gain is not None and gain > 0:
cmd.extend(['-g', self._format_gain(gain)])
if bias_t:
cmd.append('-T')
# Output to stdout
cmd.append('-')
return cmd
def get_capabilities(self) -> SDRCapabilities:
"""Return Airspy capabilities."""
return self.CAPABILITIES

View File

@@ -186,6 +186,41 @@ class CommandBuilder(ABC):
"""Return hardware capabilities for this SDR type."""
pass
def build_iq_capture_command(
self,
device: SDRDevice,
frequency_mhz: float,
sample_rate: int = 2048000,
gain: Optional[float] = None,
ppm: Optional[int] = None,
bias_t: bool = False,
output_format: str = 'cu8',
) -> list[str]:
"""
Build raw I/Q capture command for streaming samples to stdout.
Used for real-time waterfall/spectrum display. Output is unsigned
8-bit I/Q pairs (cu8) written continuously to stdout.
Args:
device: The SDR device to use
frequency_mhz: Center frequency in MHz
sample_rate: Sample rate in Hz (default 2048000)
gain: Gain in dB (None for auto)
ppm: PPM frequency correction
bias_t: Enable bias-T power (for active antennas)
output_format: Output sample format (default 'cu8')
Returns:
Command as list of strings for subprocess
Raises:
NotImplementedError: If the SDR type does not support I/Q capture.
"""
raise NotImplementedError(
f"{self.__class__.__name__} does not support raw I/Q capture"
)
@classmethod
@abstractmethod
def get_sdr_type(cls) -> SDRType:

View File

@@ -185,6 +185,44 @@ class HackRFCommandBuilder(CommandBuilder):
return cmd
def build_iq_capture_command(
self,
device: SDRDevice,
frequency_mhz: float,
sample_rate: int = 2048000,
gain: Optional[float] = None,
ppm: Optional[int] = None,
bias_t: bool = False,
output_format: str = 'cu8',
) -> list[str]:
"""
Build rx_sdr command for raw I/Q capture with HackRF.
Outputs unsigned 8-bit I/Q pairs to stdout for waterfall display.
"""
device_str = self._build_device_string(device)
freq_hz = int(frequency_mhz * 1e6)
cmd = [
'rx_sdr',
'-d', device_str,
'-f', str(freq_hz),
'-s', str(sample_rate),
'-F', 'CU8',
]
if gain is not None and gain > 0:
lna, vga = self._split_gain(gain)
cmd.extend(['-g', f'LNA={lna},VGA={vga}'])
if bias_t:
cmd.append('-T')
# Output to stdout
cmd.append('-')
return cmd
def get_capabilities(self) -> SDRCapabilities:
"""Return HackRF capabilities."""
return self.CAPABILITIES

View File

@@ -162,6 +162,41 @@ class LimeSDRCommandBuilder(CommandBuilder):
return cmd
def build_iq_capture_command(
self,
device: SDRDevice,
frequency_mhz: float,
sample_rate: int = 2048000,
gain: Optional[float] = None,
ppm: Optional[int] = None,
bias_t: bool = False,
output_format: str = 'cu8',
) -> list[str]:
"""
Build rx_sdr command for raw I/Q capture with LimeSDR.
Outputs unsigned 8-bit I/Q pairs to stdout for waterfall display.
Note: LimeSDR does not support bias-T, parameter is ignored.
"""
device_str = self._build_device_string(device)
freq_hz = int(frequency_mhz * 1e6)
cmd = [
'rx_sdr',
'-d', device_str,
'-f', str(freq_hz),
'-s', str(sample_rate),
'-F', 'CU8',
]
if gain is not None and gain > 0:
cmd.extend(['-g', f'LNAH={int(gain)}'])
# Output to stdout
cmd.append('-')
return cmd
def get_capabilities(self) -> SDRCapabilities:
"""Return LimeSDR capabilities."""
return self.CAPABILITIES

View File

@@ -231,6 +231,45 @@ class RTLSDRCommandBuilder(CommandBuilder):
return cmd
def build_iq_capture_command(
self,
device: SDRDevice,
frequency_mhz: float,
sample_rate: int = 2048000,
gain: Optional[float] = None,
ppm: Optional[int] = None,
bias_t: bool = False,
output_format: str = 'cu8',
) -> list[str]:
"""
Build rtl_sdr command for raw I/Q capture.
Outputs unsigned 8-bit I/Q pairs to stdout for waterfall display.
"""
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.append('-T')
# Output to stdout
cmd.append('-')
return cmd
def get_capabilities(self) -> SDRCapabilities:
"""Return RTL-SDR capabilities."""
return self.CAPABILITIES

View File

@@ -163,6 +163,43 @@ class SDRPlayCommandBuilder(CommandBuilder):
return cmd
def build_iq_capture_command(
self,
device: SDRDevice,
frequency_mhz: float,
sample_rate: int = 2048000,
gain: Optional[float] = None,
ppm: Optional[int] = None,
bias_t: bool = False,
output_format: str = 'cu8',
) -> list[str]:
"""
Build rx_sdr command for raw I/Q capture with SDRPlay.
Outputs unsigned 8-bit I/Q pairs to stdout for waterfall display.
"""
device_str = self._build_device_string(device)
freq_hz = int(frequency_mhz * 1e6)
cmd = [
'rx_sdr',
'-d', device_str,
'-f', str(freq_hz),
'-s', str(sample_rate),
'-F', 'CU8',
]
if gain is not None and gain > 0:
cmd.extend(['-g', f'IFGR={int(gain)}'])
if bias_t:
cmd.append('-T')
# Output to stdout
cmd.append('-')
return cmd
def get_capabilities(self) -> SDRCapabilities:
"""Return SDRPlay capabilities."""
return self.CAPABILITIES