style: apply ruff-format to entire codebase

First-time run of ruff-format via pre-commit hook normalises quote
style, trailing commas, and whitespace across 188 Python files.
No logic changes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
James Smith
2026-07-05 14:48:11 +01:00
parent 82e64104fe
commit 96172ca593
189 changed files with 19883 additions and 19552 deletions
+10 -10
View File
@@ -20,14 +20,14 @@ from .sstv_decoder import (
)
__all__ = [
'DecodeProgress',
'DopplerInfo',
'DopplerTracker',
'ISS_SSTV_FREQ',
'SSTV_MODES',
'SSTVDecoder',
'SSTVImage',
'get_general_sstv_decoder',
'get_sstv_decoder',
'is_sstv_available',
"DecodeProgress",
"DopplerInfo",
"DopplerTracker",
"ISS_SSTV_FREQ",
"SSTV_MODES",
"SSTVDecoder",
"SSTVImage",
"get_general_sstv_decoder",
"get_sstv_decoder",
"is_sstv_available",
]
+38 -32
View File
@@ -20,17 +20,17 @@ STREAM_CHUNK_SAMPLES = 4800
# ---------------------------------------------------------------------------
# SSTV tone frequencies (Hz)
# ---------------------------------------------------------------------------
FREQ_VIS_BIT_1 = 1100 # VIS logic 1
FREQ_SYNC = 1200 # Horizontal sync pulse
FREQ_VIS_BIT_0 = 1300 # VIS logic 0
FREQ_BREAK = 1200 # Break tone in VIS header (same as sync)
FREQ_LEADER = 1900 # Leader / calibration tone
FREQ_BLACK = 1500 # Black level
FREQ_WHITE = 2300 # White level
FREQ_VIS_BIT_1 = 1100 # VIS logic 1
FREQ_SYNC = 1200 # Horizontal sync pulse
FREQ_VIS_BIT_0 = 1300 # VIS logic 0
FREQ_BREAK = 1200 # Break tone in VIS header (same as sync)
FREQ_LEADER = 1900 # Leader / calibration tone
FREQ_BLACK = 1500 # Black level
FREQ_WHITE = 2300 # White level
# Pixel luminance mapping range
FREQ_PIXEL_LOW = 1500 # 0 luminance
FREQ_PIXEL_HIGH = 2300 # 255 luminance
FREQ_PIXEL_LOW = 1500 # 0 luminance
FREQ_PIXEL_HIGH = 2300 # 255 luminance
# Frequency tolerance for tone detection (Hz)
FREQ_TOLERANCE = 50
@@ -38,13 +38,13 @@ FREQ_TOLERANCE = 50
# ---------------------------------------------------------------------------
# VIS header timing (seconds)
# ---------------------------------------------------------------------------
VIS_LEADER_MIN = 0.200 # Minimum leader tone duration
VIS_LEADER_MAX = 0.500 # Maximum leader tone duration
VIS_LEADER_NOMINAL = 0.300 # Nominal leader tone duration
VIS_BREAK_DURATION = 0.010 # Break pulse duration (10 ms)
VIS_BIT_DURATION = 0.030 # Each VIS data bit (30 ms)
VIS_LEADER_MIN = 0.200 # Minimum leader tone duration
VIS_LEADER_MAX = 0.500 # Maximum leader tone duration
VIS_LEADER_NOMINAL = 0.300 # Nominal leader tone duration
VIS_BREAK_DURATION = 0.010 # Break pulse duration (10 ms)
VIS_BIT_DURATION = 0.030 # Each VIS data bit (30 ms)
VIS_START_BIT_DURATION = 0.030 # Start bit (30 ms)
VIS_STOP_BIT_DURATION = 0.030 # Stop bit (30 ms)
VIS_STOP_BIT_DURATION = 0.030 # Stop bit (30 ms)
# Timing tolerance for VIS detection
VIS_TIMING_TOLERANCE = 0.5 # 50% tolerance on durations
@@ -53,22 +53,22 @@ VIS_TIMING_TOLERANCE = 0.5 # 50% tolerance on durations
# VIS code → mode name mapping
# ---------------------------------------------------------------------------
VIS_CODES: dict[int, str] = {
8: 'Robot36',
12: 'Robot72',
44: 'Martin1',
40: 'Martin2',
60: 'Scottie1',
56: 'Scottie2',
95: 'PD120',
97: 'PD180',
8: "Robot36",
12: "Robot72",
44: "Martin1",
40: "Martin2",
60: "Scottie1",
56: "Scottie2",
95: "PD120",
97: "PD180",
# Less common but recognized
4: 'Robot24',
36: 'Martin3',
52: 'Scottie3',
76: 'ScottieDX',
96: 'PD240',
99: 'PD90',
98: 'PD160',
4: "Robot24",
36: "Martin3",
52: "Scottie3",
76: "ScottieDX",
96: "PD240",
99: "PD90",
98: "PD160",
}
# Reverse mapping: mode name → VIS code
@@ -78,8 +78,14 @@ MODE_TO_VIS: dict[str, int] = {v: k for k, v in VIS_CODES.items()}
# Common SSTV modes list (for UI / status)
# ---------------------------------------------------------------------------
SSTV_MODES = [
'PD120', 'PD180', 'Martin1', 'Martin2',
'Scottie1', 'Scottie2', 'Robot36', 'Robot72',
"PD120",
"PD180",
"Martin1",
"Martin2",
"Scottie1",
"Scottie2",
"Robot36",
"Robot72",
]
# ISS SSTV frequency
+16 -15
View File
@@ -18,8 +18,7 @@ from .constants import (
)
def goertzel(samples: np.ndarray, target_freq: float,
sample_rate: int = SAMPLE_RATE) -> float:
def goertzel(samples: np.ndarray, target_freq: float, sample_rate: int = SAMPLE_RATE) -> float:
"""Compute Goertzel energy at a single target frequency.
O(N) per frequency - more efficient than FFT when only a few
@@ -56,8 +55,7 @@ def goertzel(samples: np.ndarray, target_freq: float,
return s1 * s1 + s2 * s2 - coeff * s1 * s2
def goertzel_mag(samples: np.ndarray, target_freq: float,
sample_rate: int = SAMPLE_RATE) -> float:
def goertzel_mag(samples: np.ndarray, target_freq: float, sample_rate: int = SAMPLE_RATE) -> float:
"""Compute Goertzel magnitude (square root of energy).
Args:
@@ -71,8 +69,9 @@ def goertzel_mag(samples: np.ndarray, target_freq: float,
return math.sqrt(max(0.0, goertzel(samples, target_freq, sample_rate)))
def detect_tone(samples: np.ndarray, candidates: list[float],
sample_rate: int = SAMPLE_RATE) -> tuple[float | None, float]:
def detect_tone(
samples: np.ndarray, candidates: list[float], sample_rate: int = SAMPLE_RATE
) -> tuple[float | None, float]:
"""Detect which candidate frequency has the strongest energy.
Args:
@@ -98,16 +97,20 @@ def detect_tone(samples: np.ndarray, candidates: list[float],
others = [e for f, e in energies.items() if f != max_freq]
avg_others = sum(others) / len(others) if others else 0.0
ratio = max_energy / avg_others if avg_others > 0 else float('inf')
ratio = max_energy / avg_others if avg_others > 0 else float("inf")
if ratio >= MIN_ENERGY_RATIO:
return max_freq, ratio
return None, ratio
def estimate_frequency(samples: np.ndarray, freq_low: float = 1000.0,
freq_high: float = 2500.0, step: float = 25.0,
sample_rate: int = SAMPLE_RATE) -> float:
def estimate_frequency(
samples: np.ndarray,
freq_low: float = 1000.0,
freq_high: float = 2500.0,
step: float = 25.0,
sample_rate: int = SAMPLE_RATE,
) -> float:
"""Estimate the dominant frequency in a range using Goertzel sweep.
Sweeps through frequencies in the given range and returns the one
@@ -168,8 +171,7 @@ def freq_to_pixel(frequency: float) -> int:
return max(0, min(255, int(normalized * 255 + 0.5)))
def samples_for_duration(duration_s: float,
sample_rate: int = SAMPLE_RATE) -> int:
def samples_for_duration(duration_s: float, sample_rate: int = SAMPLE_RATE) -> int:
"""Calculate number of samples for a given duration.
Args:
@@ -182,8 +184,7 @@ def samples_for_duration(duration_s: float,
return int(duration_s * sample_rate + 0.5)
def goertzel_batch(audio_matrix: np.ndarray, frequencies: np.ndarray,
sample_rate: int = SAMPLE_RATE) -> np.ndarray:
def goertzel_batch(audio_matrix: np.ndarray, frequencies: np.ndarray, sample_rate: int = SAMPLE_RATE) -> np.ndarray:
"""Compute Goertzel energy for multiple audio segments at multiple frequencies.
Vectorized implementation using numpy broadcasting. Processes all
@@ -212,7 +213,7 @@ def goertzel_batch(audio_matrix: np.ndarray, frequencies: np.ndarray,
s2 = np.zeros_like(s1)
for n in range(N):
samples_n = audio_matrix[:, n:n + 1] # (M, 1) — broadcasts with (M, F)
samples_n = audio_matrix[:, n : n + 1] # (M, 1) — broadcasts with (M, F)
s0 = samples_n + coeff * s1 - s2
s2 = s1
s1 = s0
+25 -48
View File
@@ -54,8 +54,7 @@ class SSTVImageDecoder:
image = decoder.get_image()
"""
def __init__(self, mode: SSTVMode, sample_rate: int = SAMPLE_RATE,
progress_cb: ProgressCallback | None = None):
def __init__(self, mode: SSTVMode, sample_rate: int = SAMPLE_RATE, progress_cb: ProgressCallback | None = None):
self._mode = mode
self._sample_rate = sample_rate
self._progress_cb = progress_cb
@@ -65,21 +64,16 @@ class SSTVImageDecoder:
self._complete = False
# Pre-calculate sample counts
self._sync_samples = samples_for_duration(
mode.sync_duration_ms / 1000.0, sample_rate)
self._porch_samples = samples_for_duration(
mode.sync_porch_ms / 1000.0, sample_rate)
self._line_samples = samples_for_duration(
mode.line_duration_ms / 1000.0, sample_rate)
self._sync_samples = samples_for_duration(mode.sync_duration_ms / 1000.0, sample_rate)
self._porch_samples = samples_for_duration(mode.sync_porch_ms / 1000.0, sample_rate)
self._line_samples = samples_for_duration(mode.line_duration_ms / 1000.0, sample_rate)
self._separator_samples = (
samples_for_duration(mode.channel_separator_ms / 1000.0, sample_rate)
if mode.channel_separator_ms > 0 else 0
if mode.channel_separator_ms > 0
else 0
)
self._channel_samples = [
samples_for_duration(ch.duration_ms / 1000.0, sample_rate)
for ch in mode.channels
]
self._channel_samples = [samples_for_duration(ch.duration_ms / 1000.0, sample_rate) for ch in mode.channels]
# For PD modes, each "line" of audio produces 2 image lines
if mode.color_model == ColorModel.YCRCB_DUAL:
@@ -92,11 +86,9 @@ class SSTVImageDecoder:
for _i, _ch_spec in enumerate(mode.channels):
if mode.color_model == ColorModel.YCRCB_DUAL:
# Y1, Cr, Cb, Y2 - all are width-wide
self._channel_data.append(
np.zeros((self._total_audio_lines, mode.width), dtype=np.uint8))
self._channel_data.append(np.zeros((self._total_audio_lines, mode.width), dtype=np.uint8))
else:
self._channel_data.append(
np.zeros((mode.height, mode.width), dtype=np.uint8))
self._channel_data.append(np.zeros((mode.height, mode.width), dtype=np.uint8))
# Track sync position for re-synchronization
self._expected_line_start = 0 # Sample offset within buffer
@@ -179,7 +171,7 @@ class SSTVImageDecoder:
step = window_size // 2
for pos in range(0, len(search_region) - window_size, step):
chunk = search_region[pos:pos + window_size]
chunk = search_region[pos : pos + window_size]
sync_energy = goertzel(chunk, FREQ_SYNC, self._sample_rate)
# Check it's actually sync, not data at 1200 Hz area
black_energy = goertzel(chunk, FREQ_BLACK, self._sample_rate)
@@ -229,7 +221,7 @@ class SSTVImageDecoder:
# Not enough data yet - put the data back and wait
return
channel_audio = self._buffer[pos:pos + ch_samples]
channel_audio = self._buffer[pos : pos + ch_samples]
pixels = self._decode_channel_pixels(channel_audio)
self._channel_data[ch_idx][self._current_line, :] = pixels
pos += ch_samples
@@ -251,15 +243,11 @@ class SSTVImageDecoder:
# a 2-sample/line drift; 800 samples covers ±200 ppm.
bwd = min(800, pos)
remaining = len(self._buffer) - pos
fwd = min(800, max(
0,
remaining - self._sync_samples
- self._porch_samples - r_samples))
fwd = min(800, max(0, remaining - self._sync_samples - self._porch_samples - r_samples))
deviation: float | None = None
if bwd + fwd > 0:
region_start = pos - bwd
sync_region = self._buffer[
region_start: pos + self._sync_samples + fwd]
sync_region = self._buffer[region_start : pos + self._sync_samples + fwd]
# Full-sync-length window gives best freq resolution
# (~111 Hz at 48 kHz) to cleanly separate 1200 Hz
# sync from 1500 Hz pixel data.
@@ -269,29 +257,21 @@ class SSTVImageDecoder:
# Step 5 samples (~0.1 ms) — enough resolution
# for pixel-level drift, keeps batch size small.
step = 5
all_windows = (
np.lib.stride_tricks.sliding_window_view(
sync_region, win))
all_windows = np.lib.stride_tricks.sliding_window_view(sync_region, win)
windows = all_windows[::step]
energies = goertzel_batch(
windows,
np.array([FREQ_SYNC, FREQ_BLACK]),
self._sample_rate)
energies = goertzel_batch(windows, np.array([FREQ_SYNC, FREQ_BLACK]), self._sample_rate)
sync_e = energies[:, 0]
black_e = energies[:, 1]
valid_mask = sync_e > black_e * 2
if valid_mask.any():
fine_idx = int(
np.argmax(
np.where(valid_mask, sync_e, 0.0)))
fine_idx = int(np.argmax(np.where(valid_mask, sync_e, 0.0)))
deviation = float(fine_idx * step - bwd)
self._sync_deviations.append(deviation)
pos += self._sync_samples + self._porch_samples
elif self._separator_samples > 0:
# Robot: separator + porch between channels
pos += self._separator_samples
elif (self._mode.sync_position == SyncPosition.FRONT
and self._mode.color_model == ColorModel.RGB):
elif self._mode.sync_position == SyncPosition.FRONT and self._mode.color_model == ColorModel.RGB:
# Martin: porch between channels
pos += self._porch_samples
@@ -339,10 +319,10 @@ class SSTVImageDecoder:
h = np.zeros(n)
if n % 2 == 0:
h[0] = h[n // 2] = 1
h[1:n // 2] = 2
h[1 : n // 2] = 2
else:
h[0] = 1
h[1:(n + 1) // 2] = 2
h[1 : (n + 1) // 2] = 2
analytic = np.fft.ifft(spectrum * h)
@@ -365,8 +345,7 @@ class SSTVImageDecoder:
avg_freqs = sums / segment_lengths
# Map to pixel values (1500 Hz → 0, 2300 Hz → 255)
normalized = (avg_freqs - FREQ_PIXEL_LOW) / (
FREQ_PIXEL_HIGH - FREQ_PIXEL_LOW)
normalized = (avg_freqs - FREQ_PIXEL_LOW) / (FREQ_PIXEL_HIGH - FREQ_PIXEL_LOW)
return np.clip(normalized * 255 + 0.5, 0, 255).astype(np.uint8)
def get_image(self) -> Image.Image | None:
@@ -401,8 +380,7 @@ class SSTVImageDecoder:
measurements are averaged out; if fewer than 10 valid measurements
exist the image is returned unchanged.
"""
valid = [(i, d) for i, d in enumerate(self._sync_deviations)
if d is not None]
valid = [(i, d) for i, d in enumerate(self._sync_deviations) if d is not None]
if len(valid) < 10:
return img
@@ -437,7 +415,7 @@ class SSTVImageDecoder:
shift = -int(round(row * pixels_per_line))
corrected[row] = np.roll(arr[row], shift=shift, axis=0)
return Image.fromarray(corrected, 'RGB')
return Image.fromarray(corrected, "RGB")
def _assemble_rgb(self) -> Image.Image:
"""Assemble RGB image from sequential R, G, B channel data.
@@ -452,7 +430,7 @@ class SSTVImageDecoder:
r_data = self._channel_data[2][:height]
rgb = np.stack([r_data, g_data, b_data], axis=-1)
return Image.fromarray(rgb, 'RGB')
return Image.fromarray(rgb, "RGB")
def _assemble_ycrcb(self) -> Image.Image:
"""Assemble image from YCrCb data (Robot modes).
@@ -538,8 +516,7 @@ class SSTVImageDecoder:
return self._ycrcb_to_rgb(y_full, cr_full, cb_full, height, width)
@staticmethod
def _ycrcb_to_rgb(y: np.ndarray, cr: np.ndarray, cb: np.ndarray,
height: int, width: int) -> Image.Image:
def _ycrcb_to_rgb(y: np.ndarray, cr: np.ndarray, cb: np.ndarray, height: int, width: int) -> Image.Image:
"""Convert YCrCb pixel data to an RGB PIL Image.
Uses the SSTV convention where pixel values 0-255 map to the
@@ -562,4 +539,4 @@ class SSTVImageDecoder:
b = np.clip(b, 0, 255).astype(np.uint8)
rgb = np.stack([r, g, b], axis=-1)
return Image.fromarray(rgb, 'RGB')
return Image.fromarray(rgb, "RGB")
+51 -38
View File
@@ -12,16 +12,18 @@ from dataclasses import dataclass, field
class ColorModel(enum.Enum):
"""Color encoding models used by SSTV modes."""
RGB = 'rgb' # Sequential R, G, B channels per line
YCRCB = 'ycrcb' # Luminance + chrominance (Robot modes)
YCRCB_DUAL = 'ycrcb_dual' # Dual-luminance YCrCb (PD modes)
RGB = "rgb" # Sequential R, G, B channels per line
YCRCB = "ycrcb" # Luminance + chrominance (Robot modes)
YCRCB_DUAL = "ycrcb_dual" # Dual-luminance YCrCb (PD modes)
class SyncPosition(enum.Enum):
"""Where the horizontal sync pulse appears in each line."""
FRONT = 'front' # Sync at start of line (Robot, Martin)
MIDDLE = 'middle' # Sync between G and B channels (Scottie)
FRONT_PD = 'front_pd' # PD-style sync at start
FRONT = "front" # Sync at start of line (Robot, Martin)
MIDDLE = "middle" # Sync between G and B channels (Scottie)
FRONT_PD = "front_pd" # PD-style sync at start
@dataclass(frozen=True)
@@ -31,6 +33,7 @@ class ChannelTiming:
Attributes:
duration_ms: Duration of this channel's pixel data in milliseconds.
"""
duration_ms: float
@@ -52,6 +55,7 @@ class SSTVMode:
has_half_rate_chroma: Whether chroma is sent at half vertical rate
(Robot modes: Cr and Cb alternate every other line).
"""
name: str
vis_code: int
width: int
@@ -71,7 +75,7 @@ class SSTVMode:
# ---------------------------------------------------------------------------
ROBOT_36 = SSTVMode(
name='Robot36',
name="Robot36",
vis_code=8,
width=320,
height=240,
@@ -80,8 +84,8 @@ ROBOT_36 = SSTVMode(
sync_duration_ms=9.0,
sync_porch_ms=3.0,
channels=[
ChannelTiming(duration_ms=88.0), # Y (luminance)
ChannelTiming(duration_ms=44.0), # Cr or Cb (alternating)
ChannelTiming(duration_ms=88.0), # Y (luminance)
ChannelTiming(duration_ms=44.0), # Cr or Cb (alternating)
],
line_duration_ms=150.0,
has_half_rate_chroma=True,
@@ -89,7 +93,7 @@ ROBOT_36 = SSTVMode(
)
ROBOT_72 = SSTVMode(
name='Robot72',
name="Robot72",
vis_code=12,
width=320,
height=240,
@@ -98,9 +102,9 @@ ROBOT_72 = SSTVMode(
sync_duration_ms=9.0,
sync_porch_ms=3.0,
channels=[
ChannelTiming(duration_ms=138.0), # Y (luminance)
ChannelTiming(duration_ms=69.0), # Cr
ChannelTiming(duration_ms=69.0), # Cb
ChannelTiming(duration_ms=138.0), # Y (luminance)
ChannelTiming(duration_ms=69.0), # Cr
ChannelTiming(duration_ms=69.0), # Cb
],
line_duration_ms=300.0,
has_half_rate_chroma=False,
@@ -112,7 +116,7 @@ ROBOT_72 = SSTVMode(
# ---------------------------------------------------------------------------
MARTIN_1 = SSTVMode(
name='Martin1',
name="Martin1",
vis_code=44,
width=320,
height=256,
@@ -129,7 +133,7 @@ MARTIN_1 = SSTVMode(
)
MARTIN_2 = SSTVMode(
name='Martin2',
name="Martin2",
vis_code=40,
width=320,
height=256,
@@ -138,9 +142,9 @@ MARTIN_2 = SSTVMode(
sync_duration_ms=4.862,
sync_porch_ms=0.572,
channels=[
ChannelTiming(duration_ms=73.216), # Green
ChannelTiming(duration_ms=73.216), # Blue
ChannelTiming(duration_ms=73.216), # Red
ChannelTiming(duration_ms=73.216), # Green
ChannelTiming(duration_ms=73.216), # Blue
ChannelTiming(duration_ms=73.216), # Red
],
line_duration_ms=226.798,
)
@@ -150,7 +154,7 @@ MARTIN_2 = SSTVMode(
# ---------------------------------------------------------------------------
SCOTTIE_1 = SSTVMode(
name='Scottie1',
name="Scottie1",
vis_code=60,
width=320,
height=256,
@@ -167,7 +171,7 @@ SCOTTIE_1 = SSTVMode(
)
SCOTTIE_2 = SSTVMode(
name='Scottie2',
name="Scottie2",
vis_code=56,
width=320,
height=256,
@@ -176,9 +180,9 @@ SCOTTIE_2 = SSTVMode(
sync_duration_ms=9.0,
sync_porch_ms=1.5,
channels=[
ChannelTiming(duration_ms=88.064), # Green
ChannelTiming(duration_ms=88.064), # Blue
ChannelTiming(duration_ms=88.064), # Red
ChannelTiming(duration_ms=88.064), # Green
ChannelTiming(duration_ms=88.064), # Blue
ChannelTiming(duration_ms=88.064), # Red
],
line_duration_ms=277.692,
)
@@ -188,7 +192,7 @@ SCOTTIE_2 = SSTVMode(
# ---------------------------------------------------------------------------
PD_120 = SSTVMode(
name='PD120',
name="PD120",
vis_code=95,
width=640,
height=496,
@@ -206,7 +210,7 @@ PD_120 = SSTVMode(
)
PD_180 = SSTVMode(
name='PD180',
name="PD180",
vis_code=97,
width=640,
height=496,
@@ -224,7 +228,7 @@ PD_180 = SSTVMode(
)
PD_90 = SSTVMode(
name='PD90',
name="PD90",
vis_code=99,
width=640,
height=496,
@@ -233,16 +237,16 @@ PD_90 = SSTVMode(
sync_duration_ms=20.0,
sync_porch_ms=2.080,
channels=[
ChannelTiming(duration_ms=91.520), # Y1
ChannelTiming(duration_ms=91.520), # Cr
ChannelTiming(duration_ms=91.520), # Cb
ChannelTiming(duration_ms=91.520), # Y2
ChannelTiming(duration_ms=91.520), # Y1
ChannelTiming(duration_ms=91.520), # Cr
ChannelTiming(duration_ms=91.520), # Cb
ChannelTiming(duration_ms=91.520), # Y2
],
line_duration_ms=388.160,
)
PD_160 = SSTVMode(
name='PD160',
name="PD160",
vis_code=98,
width=640,
height=496,
@@ -260,7 +264,7 @@ PD_160 = SSTVMode(
)
PD_240 = SSTVMode(
name='PD240',
name="PD240",
vis_code=96,
width=640,
height=496,
@@ -282,7 +286,7 @@ PD_240 = SSTVMode(
# ---------------------------------------------------------------------------
SCOTTIE_DX = SSTVMode(
name='ScottieDX',
name="ScottieDX",
vis_code=76,
width=320,
height=256,
@@ -304,11 +308,20 @@ SCOTTIE_DX = SSTVMode(
# ---------------------------------------------------------------------------
ALL_MODES: dict[int, SSTVMode] = {
m.vis_code: m for m in [
ROBOT_36, ROBOT_72,
MARTIN_1, MARTIN_2,
SCOTTIE_1, SCOTTIE_2, SCOTTIE_DX,
PD_90, PD_120, PD_160, PD_180, PD_240,
m.vis_code: m
for m in [
ROBOT_36,
ROBOT_72,
MARTIN_1,
MARTIN_2,
SCOTTIE_1,
SCOTTIE_2,
SCOTTIE_DX,
PD_90,
PD_120,
PD_160,
PD_180,
PD_240,
]
}
+160 -159
View File
@@ -35,7 +35,7 @@ from .image_decoder import SSTVImageDecoder
from .modes import get_mode
from .vis import VISDetector
logger = get_logger('intercept.sstv')
logger = get_logger("intercept.sstv")
try:
from PIL import Image as PILImage
@@ -56,59 +56,61 @@ except ImportError:
@dataclass
class SSTVImage:
"""Decoded SSTV image."""
filename: str
path: Path
mode: str
timestamp: datetime
frequency: float
size_bytes: int = 0
url_prefix: str = '/sstv'
url_prefix: str = "/sstv"
def to_dict(self) -> dict:
return {
'filename': self.filename,
'path': str(self.path),
'mode': self.mode,
'timestamp': self.timestamp.isoformat(),
'frequency': self.frequency,
'size_bytes': self.size_bytes,
'url': f'{self.url_prefix}/images/{self.filename}'
"filename": self.filename,
"path": str(self.path),
"mode": self.mode,
"timestamp": self.timestamp.isoformat(),
"frequency": self.frequency,
"size_bytes": self.size_bytes,
"url": f"{self.url_prefix}/images/{self.filename}",
}
@dataclass
class DecodeProgress:
"""SSTV decode progress update."""
status: str # 'detecting', 'decoding', 'complete', 'error'
mode: str | None = None
progress_percent: int = 0
message: str | None = None
image: SSTVImage | None = None
signal_level: int | None = None # 0-100 RMS audio level, None = not measured
sstv_tone: str | None = None # 'leader', 'sync', 'noise', None
vis_state: str | None = None # VIS detector state name
sstv_tone: str | None = None # 'leader', 'sync', 'noise', None
vis_state: str | None = None # VIS detector state name
partial_image: str | None = None # base64 data URL of partial decode
def to_dict(self) -> dict:
result: dict = {
'type': 'sstv_progress',
'status': self.status,
'progress': self.progress_percent,
"type": "sstv_progress",
"status": self.status,
"progress": self.progress_percent,
}
if self.mode:
result['mode'] = self.mode
result["mode"] = self.mode
if self.message:
result['message'] = self.message
result["message"] = self.message
if self.image:
result['image'] = self.image.to_dict()
result["image"] = self.image.to_dict()
if self.signal_level is not None:
result['signal_level'] = self.signal_level
result["signal_level"] = self.signal_level
if self.sstv_tone:
result['sstv_tone'] = self.sstv_tone
result["sstv_tone"] = self.sstv_tone
if self.vis_state:
result['vis_state'] = self.vis_state
result["vis_state"] = self.vis_state
if self.partial_image:
result['partial_image'] = self.partial_image
result["partial_image"] = self.partial_image
return result
@@ -131,29 +133,30 @@ def _encode_scope_waveform(raw_samples: np.ndarray, window_size: int = 256) -> l
# SSTVDecoder
# ---------------------------------------------------------------------------
class SSTVDecoder:
"""SSTV decoder using pure-Python DSP with Doppler compensation."""
RETUNE_THRESHOLD_HZ = 500
DOPPLER_UPDATE_INTERVAL = 5
def __init__(self, output_dir: str | Path | None = None, url_prefix: str = '/sstv'):
def __init__(self, output_dir: str | Path | None = None, url_prefix: str = "/sstv"):
self._rtl_process = None
self._running = False
self._lock = threading.Lock()
self._callback: Callable[[dict], None] | None = None
self._output_dir = Path(output_dir) if output_dir else Path('instance/sstv_images')
self._output_dir = Path(output_dir) if output_dir else Path("instance/sstv_images")
self._url_prefix = url_prefix
self._images: list[SSTVImage] = []
self._decode_thread = None
self._doppler_thread = None
self._frequency = ISS_SSTV_FREQ
self._modulation = 'fm'
self._modulation = "fm"
self._current_tuned_freq_hz: int = 0
self._device_index = 0
# Doppler tracking
self._doppler_tracker = DopplerTracker('ISS')
self._doppler_tracker = DopplerTracker("ISS")
self._doppler_enabled = False
self._last_doppler_info: DopplerInfo | None = None
@@ -167,7 +170,7 @@ class SSTVDecoder:
@property
def decoder_available(self) -> str:
"""Return name of available decoder. Always available with pure Python."""
return 'python-sstv'
return "python-sstv"
def set_callback(self, callback: Callable[[dict], None]) -> None:
"""Set callback for decode progress updates."""
@@ -179,7 +182,7 @@ class SSTVDecoder:
device_index: int = 0,
latitude: float | None = None,
longitude: float | None = None,
modulation: str = 'fm',
modulation: str = "fm",
) -> bool:
"""Start SSTV decoder listening on specified frequency.
@@ -220,30 +223,24 @@ class SSTVDecoder:
# Start Doppler tracking thread if enabled
if self._doppler_enabled:
self._doppler_thread = threading.Thread(
target=self._doppler_tracking_loop, daemon=True)
self._doppler_thread = threading.Thread(target=self._doppler_tracking_loop, daemon=True)
self._doppler_thread.start()
logger.info(f"SSTV decoder started on {frequency} MHz with Doppler tracking")
self._emit_progress(DecodeProgress(
status='detecting',
message=f'Listening on {frequency} MHz with Doppler tracking...'
))
self._emit_progress(
DecodeProgress(
status="detecting", message=f"Listening on {frequency} MHz with Doppler tracking..."
)
)
else:
logger.info(f"SSTV decoder started on {frequency} MHz (no Doppler tracking)")
self._emit_progress(DecodeProgress(
status='detecting',
message=f'Listening on {frequency} MHz...'
))
self._emit_progress(DecodeProgress(status="detecting", message=f"Listening on {frequency} MHz..."))
return True
except Exception as e:
self._running = False
logger.error(f"Failed to start SSTV decoder: {e}")
self._emit_progress(DecodeProgress(
status='error',
message=str(e)
))
self._emit_progress(DecodeProgress(status="error", message=str(e)))
return False
def _get_doppler_corrected_freq_hz(self) -> int:
@@ -267,27 +264,28 @@ class SSTVDecoder:
def _start_pipeline(self, freq_hz: int) -> None:
"""Start the rtl_fm -> Python decode pipeline."""
rtl_cmd = [
'rtl_fm',
'-d', str(self._device_index),
'-f', str(freq_hz),
'-M', self._modulation,
'-s', str(SAMPLE_RATE),
'-r', str(SAMPLE_RATE),
'-l', '0', # No squelch
'-'
"rtl_fm",
"-d",
str(self._device_index),
"-f",
str(freq_hz),
"-M",
self._modulation,
"-s",
str(SAMPLE_RATE),
"-r",
str(SAMPLE_RATE),
"-l",
"0", # No squelch
"-",
]
logger.info(f"Starting rtl_fm: {' '.join(rtl_cmd)}")
self._rtl_process = subprocess.Popen(
rtl_cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE
)
self._rtl_process = subprocess.Popen(rtl_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
# Start decode thread that reads from rtl_fm stdout
self._decode_thread = threading.Thread(
target=self._decode_audio_stream, daemon=True)
self._decode_thread = threading.Thread(target=self._decode_audio_stream, daemon=True)
self._decode_thread.start()
def _decode_audio_stream(self) -> None:
@@ -304,7 +302,7 @@ class SSTVDecoder:
last_partial_pct = -1
logger.info("Audio decode thread started")
rtl_fm_error: str = ''
rtl_fm_error: str = ""
while self._running and self._rtl_process:
try:
@@ -312,16 +310,12 @@ class SSTVDecoder:
if not raw_data:
if self._running:
# Read stderr to diagnose why rtl_fm exited
stderr_msg = ''
stderr_msg = ""
if self._rtl_process and self._rtl_process.stderr:
with contextlib.suppress(Exception):
stderr_msg = self._rtl_process.stderr.read().decode(
errors='replace').strip()
stderr_msg = self._rtl_process.stderr.read().decode(errors="replace").strip()
rc = self._rtl_process.poll() if self._rtl_process else None
logger.warning(
f"rtl_fm stream ended unexpectedly "
f"(exit code: {rc})"
)
logger.warning(f"rtl_fm stream ended unexpectedly (exit code: {rc})")
if stderr_msg:
logger.warning(f"rtl_fm stderr: {stderr_msg}")
rtl_fm_error = stderr_msg
@@ -331,7 +325,7 @@ class SSTVDecoder:
n_samples = len(raw_data) // 2
if n_samples == 0:
continue
raw_samples = np.frombuffer(raw_data[:n_samples * 2], dtype=np.int16)
raw_samples = np.frombuffer(raw_data[: n_samples * 2], dtype=np.int16)
samples = normalize_audio(raw_samples)
chunk_counter += 1
@@ -354,21 +348,23 @@ class SSTVDecoder:
img = image_decoder.get_image()
if img is not None:
buf = io.BytesIO()
img.save(buf, format='JPEG', quality=40)
b64 = base64.b64encode(buf.getvalue()).decode('ascii')
partial_url = f'data:image/jpeg;base64,{b64}'
img.save(buf, format="JPEG", quality=40)
b64 = base64.b64encode(buf.getvalue()).decode("ascii")
partial_url = f"data:image/jpeg;base64,{b64}"
except Exception:
pass
# Emit progress
self._emit_progress(DecodeProgress(
status='decoding',
mode=current_mode_name,
progress_percent=pct,
message=f'Decoding {current_mode_name}: {pct}%',
partial_image=partial_url,
))
self._emit_scope(rms_val, peak_val, 'decoding', waveform)
self._emit_progress(
DecodeProgress(
status="decoding",
mode=current_mode_name,
progress_percent=pct,
message=f"Decoding {current_mode_name}: {pct}%",
partial_image=partial_url,
)
)
self._emit_scope(rms_val, peak_val, "decoding", waveform)
if complete:
# Save image
@@ -386,8 +382,10 @@ class SSTVDecoder:
# the image decoder before the next chunk arrives.
remaining = vis_detector.remaining_buffer.copy()
vis_detector.reset()
logger.info(f"VIS detected: code={vis_code}, mode={mode_name}, "
f"{len(remaining)} image-start samples retained")
logger.info(
f"VIS detected: code={vis_code}, mode={mode_name}, "
f"{len(remaining)} image-start samples retained"
)
mode_spec = get_mode(vis_code)
if mode_spec:
@@ -399,23 +397,27 @@ class SSTVDecoder:
)
if len(remaining) > 0:
image_decoder.feed(remaining)
self._emit_progress(DecodeProgress(
status='decoding',
mode=mode_name,
progress_percent=0,
message=f'Detected {mode_name} - decoding...'
))
self._emit_progress(
DecodeProgress(
status="decoding",
mode=mode_name,
progress_percent=0,
message=f"Detected {mode_name} - decoding...",
)
)
else:
logger.warning(f"No mode spec for VIS code {vis_code}")
self._emit_progress(DecodeProgress(
status='detecting',
message=f'Detected unknown mode (VIS {vis_code}: {mode_name}) - unsupported',
))
self._emit_progress(
DecodeProgress(
status="detecting",
message=f"Detected unknown mode (VIS {vis_code}: {mode_name}) - unsupported",
)
)
# Emit signal level metrics every ~500ms (every 5th 100ms chunk)
scope_tone: str | None = None
if chunk_counter % 5 == 0 and image_decoder is None:
rms = float(np.sqrt(np.mean(samples ** 2)))
rms = float(np.sqrt(np.mean(samples**2)))
signal_level = min(100, int(rms * 500))
leader_energy = goertzel_mag(samples, 1900.0, SAMPLE_RATE)
@@ -425,26 +427,26 @@ class SSTVDecoder:
# Require the tone to both exceed the noise floor AND
# dominate the other tone by 2x to avoid false positives
# from broadband noise.
if (leader_energy > noise_floor * 5
and leader_energy > sync_energy * 2):
sstv_tone = 'leader'
elif (sync_energy > noise_floor * 5
and sync_energy > leader_energy * 2):
sstv_tone = 'sync'
if leader_energy > noise_floor * 5 and leader_energy > sync_energy * 2:
sstv_tone = "leader"
elif sync_energy > noise_floor * 5 and sync_energy > leader_energy * 2:
sstv_tone = "sync"
elif signal_level > 10:
sstv_tone = 'noise'
sstv_tone = "noise"
else:
sstv_tone = None
scope_tone = sstv_tone
self._emit_progress(DecodeProgress(
status='detecting',
message='Listening...',
signal_level=signal_level,
sstv_tone=sstv_tone,
vis_state=vis_detector.state.value,
))
self._emit_progress(
DecodeProgress(
status="detecting",
message="Listening...",
signal_level=signal_level,
sstv_tone=sstv_tone,
vis_state=vis_detector.state.value,
)
)
self._emit_scope(rms_val, peak_val, scope_tone, waveform)
@@ -473,37 +475,32 @@ class SSTVDecoder:
if was_running:
logger.warning("Audio decode thread stopped unexpectedly")
err_detail = rtl_fm_error.split('\n')[-1] if rtl_fm_error else ''
msg = f'rtl_fm failed: {err_detail}' if err_detail else 'Decode pipeline stopped unexpectedly'
self._emit_progress(DecodeProgress(
status='error',
message=msg
))
err_detail = rtl_fm_error.split("\n")[-1] if rtl_fm_error else ""
msg = f"rtl_fm failed: {err_detail}" if err_detail else "Decode pipeline stopped unexpectedly"
self._emit_progress(DecodeProgress(status="error", message=msg))
else:
logger.info("Audio decode thread stopped")
def _save_decoded_image(self, decoder: SSTVImageDecoder,
mode_name: str | None) -> None:
def _save_decoded_image(self, decoder: SSTVImageDecoder, mode_name: str | None) -> None:
"""Save a completed decoded image to disk."""
try:
img = decoder.get_image()
if img is None:
logger.error("Failed to get image from decoder (Pillow not available?)")
self._emit_progress(DecodeProgress(
status='error',
message='Failed to create image - Pillow not installed'
))
self._emit_progress(
DecodeProgress(status="error", message="Failed to create image - Pillow not installed")
)
return
timestamp = datetime.now(timezone.utc)
filename = f"sstv_{timestamp.strftime('%Y%m%d_%H%M%S')}_{mode_name or 'unknown'}.png"
filepath = self._output_dir / filename
img.save(filepath, 'PNG')
img.save(filepath, "PNG")
sstv_image = SSTVImage(
filename=filename,
path=filepath,
mode=mode_name or 'Unknown',
mode=mode_name or "Unknown",
timestamp=timestamp,
frequency=self._frequency,
size_bytes=filepath.stat().st_size,
@@ -512,20 +509,19 @@ class SSTVDecoder:
self._images.append(sstv_image)
logger.info(f"SSTV image saved: {filename} ({sstv_image.size_bytes} bytes)")
self._emit_progress(DecodeProgress(
status='complete',
mode=mode_name,
progress_percent=100,
message='Image decoded',
image=sstv_image,
))
self._emit_progress(
DecodeProgress(
status="complete",
mode=mode_name,
progress_percent=100,
message="Image decoded",
image=sstv_image,
)
)
except Exception as e:
logger.error(f"Error saving decoded image: {e}")
self._emit_progress(DecodeProgress(
status='error',
message=f'Error saving image: {e}'
))
self._emit_progress(DecodeProgress(status="error", message=f"Error saving image: {e}"))
def _doppler_tracking_loop(self) -> None:
"""Background thread that monitors Doppler shift and retunes when needed."""
@@ -552,10 +548,12 @@ class SSTVDecoder:
f"diff from tuned: {freq_diff} Hz"
)
self._emit_progress(DecodeProgress(
status='detecting',
message=f'Doppler: {doppler_info.shift_hz:+.0f} Hz, elevation: {doppler_info.elevation:.1f}\u00b0'
))
self._emit_progress(
DecodeProgress(
status="detecting",
message=f"Doppler: {doppler_info.shift_hz:+.0f} Hz, elevation: {doppler_info.elevation:.1f}\u00b0",
)
)
if freq_diff >= self.RETUNE_THRESHOLD_HZ:
logger.info(
@@ -589,23 +587,25 @@ class SSTVDecoder:
# Build and start new process outside lock
rtl_cmd = [
'rtl_fm',
'-d', str(self._device_index),
'-f', str(new_freq_hz),
'-M', self._modulation,
'-s', str(SAMPLE_RATE),
'-r', str(SAMPLE_RATE),
'-l', '0',
'-'
"rtl_fm",
"-d",
str(self._device_index),
"-f",
str(new_freq_hz),
"-M",
self._modulation,
"-s",
str(SAMPLE_RATE),
"-r",
str(SAMPLE_RATE),
"-l",
"0",
"-",
]
logger.debug(f"Restarting rtl_fm: {' '.join(rtl_cmd)}")
new_proc = subprocess.Popen(
rtl_cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE
)
new_proc = subprocess.Popen(rtl_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
# Re-acquire lock to install new process
with self._lock:
@@ -665,7 +665,7 @@ class SSTVDecoder:
def delete_all_images(self) -> int:
"""Delete all decoded images. Returns count deleted."""
count = 0
for filepath in self._output_dir.glob('*.png'):
for filepath in self._output_dir.glob("*.png"):
filepath.unlink()
count += 1
self._images.clear()
@@ -676,14 +676,14 @@ class SSTVDecoder:
"""Scan output directory for images."""
known_filenames = {img.filename for img in self._images}
for filepath in self._output_dir.glob('*.png'):
for filepath in self._output_dir.glob("*.png"):
if filepath.name not in known_filenames:
try:
stat = filepath.stat()
image = SSTVImage(
filename=filepath.name,
path=filepath,
mode='Unknown',
mode="Unknown",
timestamp=datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc),
frequency=self._frequency,
size_bytes=stat.st_size,
@@ -711,9 +711,9 @@ class SSTVDecoder:
"""Emit scope signal levels to callback."""
if self._callback:
try:
payload = {'type': 'sstv_scope', 'rms': rms, 'peak': peak, 'tone': tone}
payload = {"type": "sstv_scope", "rms": rms, "peak": peak, "tone": tone}
if waveform:
payload['waveform'] = waveform
payload["waveform"] = waveform
self._callback(payload)
except Exception:
pass
@@ -739,15 +739,14 @@ class SSTVDecoder:
images: list[SSTVImage] = []
try:
with wave.open(str(audio_path), 'rb') as wf:
with wave.open(str(audio_path), "rb") as wf:
n_channels = wf.getnchannels()
sample_width = wf.getsampwidth()
file_sample_rate = wf.getframerate()
n_frames = wf.getnframes()
logger.info(
f"Decoding WAV: {n_channels}ch, {sample_width*8}bit, "
f"{file_sample_rate}Hz, {n_frames} frames"
f"Decoding WAV: {n_channels}ch, {sample_width * 8}bit, {file_sample_rate}Hz, {n_frames} frames"
)
# Read all audio data
@@ -780,7 +779,7 @@ class SSTVDecoder:
offset = 0
while offset < len(audio):
chunk = audio[offset:offset + chunk_size]
chunk = audio[offset : offset + chunk_size]
offset += chunk_size
if image_decoder is not None:
@@ -789,14 +788,16 @@ class SSTVDecoder:
img = image_decoder.get_image()
if img is not None:
timestamp = datetime.now(timezone.utc)
filename = f"sstv_{timestamp.strftime('%Y%m%d_%H%M%S')}_{current_mode_name or 'unknown'}.png"
filename = (
f"sstv_{timestamp.strftime('%Y%m%d_%H%M%S')}_{current_mode_name or 'unknown'}.png"
)
filepath = self._output_dir / filename
img.save(filepath, 'PNG')
img.save(filepath, "PNG")
sstv_image = SSTVImage(
filename=filename,
path=filepath,
mode=current_mode_name or 'Unknown',
mode=current_mode_name or "Unknown",
timestamp=timestamp,
frequency=0,
size_bytes=filepath.stat().st_size,
@@ -879,7 +880,7 @@ def get_general_sstv_decoder() -> SSTVDecoder:
global _general_decoder
if _general_decoder is None:
_general_decoder = SSTVDecoder(
output_dir='instance/sstv_general_images',
url_prefix='/sstv-general',
output_dir="instance/sstv_general_images",
url_prefix="/sstv-general",
)
return _general_decoder
+15 -18
View File
@@ -40,15 +40,16 @@ VIS_WINDOW = 480
class VISState(enum.Enum):
"""States of the VIS detection state machine."""
IDLE = 'idle'
LEADER_1 = 'leader_1'
BREAK = 'break'
LEADER_2 = 'leader_2'
START_BIT = 'start_bit'
DATA_BITS = 'data_bits'
PARITY = 'parity'
STOP_BIT = 'stop_bit'
DETECTED = 'detected'
IDLE = "idle"
LEADER_1 = "leader_1"
BREAK = "break"
LEADER_2 = "leader_2"
START_BIT = "start_bit"
DATA_BITS = "data_bits"
PARITY = "parity"
STOP_BIT = "stop_bit"
DETECTED = "detected"
# The four tone classes we need to distinguish in VIS detection.
@@ -56,8 +57,7 @@ _VIS_FREQS = [FREQ_VIS_BIT_1, FREQ_SYNC, FREQ_VIS_BIT_0, FREQ_LEADER]
# 1100, 1200, 1300, 1900 Hz
def _classify_tone(samples: np.ndarray,
sample_rate: int = SAMPLE_RATE) -> float | None:
def _classify_tone(samples: np.ndarray, sample_rate: int = SAMPLE_RATE) -> float | None:
"""Classify which VIS tone is present in the given samples.
Computes Goertzel energy at each of the four VIS frequencies and returns
@@ -78,8 +78,7 @@ def _classify_tone(samples: np.ndarray,
# Require the best frequency to be at least 2x stronger than the
# next-strongest tone.
others = sorted(
[e for f, e in energies.items() if f != best_freq], reverse=True)
others = sorted([e for f, e in energies.items() if f != best_freq], reverse=True)
second_best = others[0] if others else 0.0
if second_best > 0 and best_energy / second_best < 2.0:
@@ -170,8 +169,8 @@ class VISDetector:
self._buffer = np.concatenate([self._buffer, samples])
while len(self._buffer) >= self._window:
result = self._process_window(self._buffer[:self._window])
self._buffer = self._buffer[self._window:]
result = self._process_window(self._buffer[: self._window])
self._buffer = self._buffer[self._window :]
if result is not None:
return result
@@ -387,9 +386,7 @@ class VISDetector:
for flip in range(8):
corrected = vis_code ^ (1 << flip)
# Flipping one data bit should fix parity too
corrected_parity_ok = (
bin(corrected).count('1') + self._parity_bit
) % 2 == 0
corrected_parity_ok = (bin(corrected).count("1") + self._parity_bit) % 2 == 0
if corrected_parity_ok:
mode_name = VIS_CODES.get(corrected)
if mode_name is not None: