mirror of
https://github.com/smittix/intercept.git
synced 2026-07-07 00:58:12 -07:00
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:
+10
-10
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user