[feat](trx-noaa): add NOAA APT satellite image decoder

New trx-noaa crate: FFT-based Hilbert transform (rustfft) for 2400 Hz
AM demodulation, sync A detection via cross-correlation, line assembly
at 4160 Hz, and JPEG output via the image crate.

- trx-core: NoaaImage type, DecodedMessage::NoaaImage variant,
  noaa_decode_enabled/noaa_decode_reset_seq on RigState/RigSnapshot,
  AUDIO_MSG_NOAA_IMAGE = 0x16
- trx-server: DecoderHistories::noaa, run_noaa_decoder task (activates
  on noaa_decode_enabled, auto-finalises after 30 s silence), saves
  JPEGs to ~/.cache/trx-rs/noaa/<YYYY-MM-DD_HH-MM-SS>.jpg, forwards
  events over TCP audio channel and history replay

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
This commit is contained in:
2026-03-28 07:00:24 +01:00
parent a8b19227d5
commit 4b40d44814
12 changed files with 852 additions and 5 deletions
+14
View File
@@ -0,0 +1,14 @@
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
#
# SPDX-License-Identifier: BSD-2-Clause
[package]
name = "trx-noaa"
version.workspace = true
edition = "2021"
[dependencies]
trx-core = { path = "../../trx-core" }
rustfft = "6"
num-complex = "0.4"
image = { version = "0.24", default-features = false, features = ["jpeg"] }
+328
View File
@@ -0,0 +1,328 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: BSD-2-Clause
//! APT (Automatic Picture Transmission) demodulator and line decoder.
//!
//! NOAA APT signal chain:
//! FM-demodulated audio → 2400 Hz AM subcarrier → envelope → 4160 Hz image
//!
//! Frame layout at 4160 Hz (2080 samples = 0.5 s per line, 2 lines/sec):
//! [SyncA 39][SpaceA 47][ImageA 909][TelA 45][SyncB 39][SpaceB 47][ImageB 909][TelB 45]
use num_complex::Complex;
use rustfft::FftPlanner;
use std::sync::Arc;
pub const APT_RATE: u32 = 4160;
pub const LINE_SAMPLES: usize = 2080;
// Line layout offsets (samples into a line at APT_RATE Hz)
pub const SYNC_A_LEN: usize = 39;
const SPACE_A_LEN: usize = 47;
pub const IMAGE_A_LEN: usize = 909;
const TEL_A_LEN: usize = 45;
const SYNC_B_LEN: usize = 39;
const SPACE_B_LEN: usize = 47;
pub const IMAGE_B_LEN: usize = 909;
pub const IMAGE_A_OFFSET: usize = SYNC_A_LEN + SPACE_A_LEN; // 86
pub const IMAGE_B_OFFSET: usize =
IMAGE_A_OFFSET + IMAGE_A_LEN + TEL_A_LEN + SYNC_B_LEN + SPACE_B_LEN; // 1126
// FFT block size for Hilbert-based AM demodulation
const BLOCK_SIZE: usize = 4096;
// Sync detection parameters
const SYNC_THRESHOLD: f32 = 0.15;
const SYNC_SEARCH_LOCKED: usize = 12; // ±samples around expected sync position when locked
const MAX_BAD_SYNC_LINES: u32 = 8; // unlock after this many low-confidence lines
/// A decoded APT line: raw pixel arrays for both image channels.
#[derive(Clone)]
pub struct RawLine {
pub pixels_a: Box<[u8; IMAGE_A_LEN]>,
pub pixels_b: Box<[u8; IMAGE_B_LEN]>,
pub line_no: u32,
}
/// Sync A reference pattern at APT_RATE Hz.
///
/// 1040 Hz square wave (period = 4 samples): alternating pairs hi/lo.
fn sync_a_ref() -> [f32; SYNC_A_LEN] {
let mut p = [0.0f32; SYNC_A_LEN];
for (i, v) in p.iter_mut().enumerate() {
// 7 cycles of alternating pairs, rest is zero (end-of-sync blank)
*v = if i < 28 && (i % 4) < 2 { 1.0 } else { -1.0 };
}
p
}
/// Compute normalised cross-correlation of `buf[offset..]` with the sync A
/// reference pattern. Returns a value approximately in `[-1.0, 1.0]`.
fn sync_score(buf: &[f32], offset: usize) -> f32 {
if offset + SYNC_A_LEN > buf.len() {
return 0.0;
}
let ref_pat = sync_a_ref();
let window = &buf[offset..offset + SYNC_A_LEN];
let mean = window.iter().sum::<f32>() / SYNC_A_LEN as f32;
let rms =
(window.iter().map(|&x| (x - mean) * (x - mean)).sum::<f32>() / SYNC_A_LEN as f32).sqrt();
if rms < 1e-7 {
return 0.0;
}
window
.iter()
.zip(ref_pat.iter())
.map(|(&s, &r)| (s - mean) * r)
.sum::<f32>()
/ (SYNC_A_LEN as f32 * rms)
}
/// Find the offset in `buf[0..search_len]` with the highest sync A score.
/// Returns `(offset, score)`.
fn find_best_sync(buf: &[f32], search_len: usize) -> (usize, f32) {
let limit = search_len.min(buf.len().saturating_sub(SYNC_A_LEN));
let mut best_off = 0usize;
let mut best_score = f32::NEG_INFINITY;
for off in 0..=limit {
let s = sync_score(buf, off);
if s > best_score {
best_score = s;
best_off = off;
}
}
(best_off, best_score)
}
// ---------------------------------------------------------------------------
// AM demodulator (Hilbert-based via rustfft)
// ---------------------------------------------------------------------------
/// Converts PCM at `sample_rate` to APT envelope samples at `APT_RATE` Hz.
///
/// Uses an FFT-based Hilbert transform to obtain the analytic signal, then
/// extracts the AM envelope. Processes input in non-overlapping blocks of
/// `BLOCK_SIZE` samples.
pub struct AptDemod {
fft_fwd: Arc<dyn rustfft::Fft<f32>>,
fft_inv: Arc<dyn rustfft::Fft<f32>>,
/// Lower bin index of the 2400 Hz ± 1040 Hz bandpass filter.
k_lo: usize,
/// Upper bin index of the 2400 Hz ± 1040 Hz bandpass filter.
k_hi: usize,
/// Input sample accumulation buffer.
in_buf: Vec<f32>,
/// Fractional position into the next input block for the resampler.
resamp_phase: f64,
/// Input samples consumed per APT_RATE output sample.
resamp_step: f64,
/// Output envelope buffer at APT_RATE Hz.
pub out: Vec<f32>,
}
impl AptDemod {
pub fn new(sample_rate: u32) -> Self {
let mut planner = FftPlanner::new();
let fft_fwd = planner.plan_fft_forward(BLOCK_SIZE);
let fft_inv = planner.plan_fft_inverse(BLOCK_SIZE);
let fs = sample_rate as f64;
// Bandpass around 2400 Hz carrier, ±1040 Hz (APT image bandwidth)
let k_lo = ((1360.0 * BLOCK_SIZE as f64 / fs).floor() as usize).max(1);
let k_hi = ((3440.0 * BLOCK_SIZE as f64 / fs).ceil() as usize).min(BLOCK_SIZE / 2);
Self {
fft_fwd,
fft_inv,
k_lo,
k_hi,
in_buf: Vec::new(),
resamp_phase: 0.0,
resamp_step: sample_rate as f64 / APT_RATE as f64,
out: Vec::new(),
}
}
/// Push raw PCM samples; envelope output accumulates in `self.out`.
pub fn push(&mut self, samples: &[f32]) {
self.in_buf.extend_from_slice(samples);
while self.in_buf.len() >= BLOCK_SIZE {
// Drain exactly BLOCK_SIZE samples into a stack array
let block: Vec<f32> = self.in_buf.drain(..BLOCK_SIZE).collect();
self.process_block(&block);
}
}
fn process_block(&mut self, block: &[f32]) {
// Forward FFT
let mut spectrum: Vec<Complex<f32>> = block.iter().map(|&s| Complex::new(s, 0.0)).collect();
self.fft_fwd.process(&mut spectrum);
// Bandpass + analytic signal:
// - Keep positive-freq band [k_lo, k_hi], doubled (for single-sideband power).
// - Zero all other bins (negative freqs and out-of-band positives).
// IFFT of the resulting one-sided spectrum ≈ analytic signal of the
// bandpass-filtered input; its magnitude is the AM envelope.
for (k, bin) in spectrum.iter_mut().enumerate() {
if k >= self.k_lo && k <= self.k_hi {
*bin *= 2.0;
} else {
*bin = Complex::ZERO;
}
}
// Inverse FFT → complex analytic signal
self.fft_inv.process(&mut spectrum);
let scale = 1.0_f32 / BLOCK_SIZE as f32;
// Magnitude = AM envelope; resample to APT_RATE via linear interpolation
let n = BLOCK_SIZE as f64;
while self.resamp_phase + 1.0 < n {
let i = self.resamp_phase as usize;
let frac = (self.resamp_phase - i as f64) as f32;
let s0 = spectrum[i].norm() * scale;
let s1 = spectrum[i + 1].norm() * scale;
self.out.push(s0 + frac * (s1 - s0));
self.resamp_phase += self.resamp_step;
}
self.resamp_phase -= n;
if self.resamp_phase < 0.0 {
self.resamp_phase = 0.0;
}
}
pub fn reset(&mut self) {
self.in_buf.clear();
self.out.clear();
self.resamp_phase = 0.0;
}
}
// ---------------------------------------------------------------------------
// Sync tracker and line assembler
// ---------------------------------------------------------------------------
/// Consumes resampled APT envelope samples (at `APT_RATE` Hz), detects
/// sync A markers, and assembles decoded image lines.
pub struct SyncTracker {
buf: Vec<f32>,
locked: bool,
bad_sync_count: u32,
line_no: u32,
pub lines: Vec<RawLine>,
}
impl Default for SyncTracker {
fn default() -> Self {
Self::new()
}
}
impl SyncTracker {
pub fn new() -> Self {
Self {
buf: Vec::new(),
locked: false,
bad_sync_count: 0,
line_no: 0,
lines: Vec::new(),
}
}
/// Push envelope samples; fully assembled lines accumulate in `self.lines`.
pub fn push(&mut self, samples: &[f32]) {
self.buf.extend_from_slice(samples);
self.drain();
}
fn drain(&mut self) {
loop {
if !self.locked {
// Need 2 × LINE_SAMPLES to have room for a full line after scan
if self.buf.len() < 2 * LINE_SAMPLES {
break;
}
let (pos, score) = find_best_sync(&self.buf, LINE_SAMPLES);
if score >= SYNC_THRESHOLD {
self.buf.drain(0..pos);
self.locked = true;
// Fall through to locked extraction below
} else {
// No convincing sync yet; discard half a line and retry
self.buf.drain(0..LINE_SAMPLES / 2);
continue;
}
}
// Locked mode: need LINE_SAMPLES + search window to be available
if self.buf.len() < LINE_SAMPLES + SYNC_SEARCH_LOCKED {
break;
}
// Refine within a small window around the expected line start
let (drift, score) = find_best_sync(&self.buf, SYNC_SEARCH_LOCKED);
if score >= SYNC_THRESHOLD * 0.5 {
// Accept refined position
if drift > 0 {
self.buf.drain(0..drift);
}
self.bad_sync_count = 0;
} else {
self.bad_sync_count += 1;
if self.bad_sync_count >= MAX_BAD_SYNC_LINES {
self.locked = false;
self.bad_sync_count = 0;
// Discard the current suspect region and restart search
self.buf.drain(0..LINE_SAMPLES / 2);
continue;
}
// Keep going at the expected position (don't drift on bad sync)
}
if self.buf.len() < LINE_SAMPLES {
break;
}
self.extract_line();
}
}
fn extract_line(&mut self) {
let samples: Vec<f32> = self.buf.drain(0..LINE_SAMPLES).collect();
// Per-line normalisation: scale to [0, 255] using the 2nd98th percentile
// of this line's values to clip noise and hot pixels.
let mut sorted: Vec<f32> = samples.clone();
sorted.sort_unstable_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let p_lo = sorted[(sorted.len() * 2 / 100).max(0)];
let p_hi = sorted[(sorted.len() * 98 / 100).min(sorted.len() - 1)];
let range = (p_hi - p_lo).max(1e-6);
let norm = |v: f32| -> u8 { ((v - p_lo) / range * 255.0).round().clamp(0.0, 255.0) as u8 };
let mut pixels_a = Box::new([0u8; IMAGE_A_LEN]);
for (i, p) in pixels_a.iter_mut().enumerate() {
*p = norm(samples[IMAGE_A_OFFSET + i]);
}
let mut pixels_b = Box::new([0u8; IMAGE_B_LEN]);
for (i, p) in pixels_b.iter_mut().enumerate() {
*p = norm(samples[IMAGE_B_OFFSET + i]);
}
self.lines.push(RawLine {
pixels_a,
pixels_b,
line_no: self.line_no,
});
self.line_no += 1;
}
pub fn reset(&mut self) {
self.buf.clear();
self.locked = false;
self.bad_sync_count = 0;
self.line_no = 0;
self.lines.clear();
}
}
+43
View File
@@ -0,0 +1,43 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: BSD-2-Clause
//! APT image assembly and JPEG encoding.
//!
//! Standard output layout: channel A (visible / IR-A) on the left half and
//! channel B (IR-B / IR) on the right half, stacked vertically by line number.
use std::io::Cursor;
use image::{DynamicImage, GrayImage};
use crate::apt::{RawLine, IMAGE_A_LEN, IMAGE_B_LEN};
/// Assemble decoded lines into a JPEG image.
///
/// Returns the JPEG bytes, or `None` if `lines` is empty or encoding fails.
/// Width = `IMAGE_A_LEN + IMAGE_B_LEN` (1818 px), height = number of lines.
pub fn encode_jpeg(lines: &[RawLine], quality: u8) -> Option<Vec<u8>> {
if lines.is_empty() {
return None;
}
let width = (IMAGE_A_LEN + IMAGE_B_LEN) as u32;
let height = lines.len() as u32;
let mut pixels: Vec<u8> = Vec::with_capacity((width * height) as usize);
for line in lines {
pixels.extend_from_slice(line.pixels_a.as_ref());
pixels.extend_from_slice(line.pixels_b.as_ref());
}
let gray = GrayImage::from_raw(width, height, pixels)?;
let dynamic = DynamicImage::ImageLuma8(gray);
let mut cursor = Cursor::new(Vec::new());
dynamic
.write_to(&mut cursor, image::ImageOutputFormat::Jpeg(quality))
.ok()?;
Some(cursor.into_inner())
}
+114
View File
@@ -0,0 +1,114 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: BSD-2-Clause
//! NOAA APT satellite image decoder.
//!
//! Decodes the Automatic Picture Transmission (APT) format broadcast by
//! NOAA-15 (137.620 MHz), NOAA-18 (137.9125 MHz) and NOAA-19 (137.100 MHz).
//!
//! # Signal chain
//!
//! The input is FM-demodulated audio containing a 2400 Hz AM subcarrier.
//! The decoder:
//! 1. Extracts the AM envelope via a FFT-based Hilbert transform (rustfft).
//! 2. Resamples to 4160 Hz (the APT image sample rate).
//! 3. Detects line sync markers (1040 Hz alternating pattern).
//! 4. Assembles image lines (2080 samples each) and extracts both channels.
//!
//! Call [`AptDecoder::process_samples`] with each audio batch, then
//! [`AptDecoder::finalize`] when the pass ends to obtain JPEG bytes.
pub mod apt;
mod image_enc;
use apt::{AptDemod, SyncTracker};
/// JPEG encoding quality (0100).
const JPEG_QUALITY: u8 = 85;
/// Completed APT image returned by [`AptDecoder::finalize`].
pub struct AptImage {
/// JPEG-encoded image bytes.
pub jpeg: Vec<u8>,
/// Number of decoded image lines.
pub line_count: u32,
/// Millisecond timestamp when the first line was decoded.
pub first_line_ms: i64,
}
/// Top-level NOAA APT decoder.
///
/// Feed audio samples with [`process_samples`] and call [`finalize`] at
/// pass end to retrieve the assembled JPEG.
pub struct AptDecoder {
demod: AptDemod,
sync: SyncTracker,
first_line_ms: Option<i64>,
}
impl AptDecoder {
pub fn new(sample_rate: u32) -> Self {
Self {
demod: AptDemod::new(sample_rate),
sync: SyncTracker::new(),
first_line_ms: None,
}
}
/// Process a batch of PCM samples (float32, mono or will be treated as-is).
///
/// Returns the number of new lines decoded in this batch.
pub fn process_samples(&mut self, samples: &[f32]) -> u32 {
self.demod.push(samples);
let before = self.sync.lines.len() as u32;
// Move accumulated envelope output into the sync tracker
if !self.demod.out.is_empty() {
let envelope = std::mem::take(&mut self.demod.out);
self.sync.push(&envelope);
}
let after = self.sync.lines.len() as u32;
let new_lines = after - before;
if new_lines > 0 && self.first_line_ms.is_none() {
self.first_line_ms = Some(now_ms());
}
new_lines
}
/// Total number of lines decoded so far.
pub fn line_count(&self) -> u32 {
self.sync.lines.len() as u32
}
/// Encode all accumulated lines as a JPEG image and return the result.
///
/// Returns `None` if no lines have been decoded yet.
/// Does **not** reset the decoder; call [`reset`] afterwards if needed.
pub fn finalize(&self) -> Option<AptImage> {
let jpeg = image_enc::encode_jpeg(&self.sync.lines, JPEG_QUALITY)?;
Some(AptImage {
jpeg,
line_count: self.sync.lines.len() as u32,
first_line_ms: self.first_line_ms.unwrap_or_else(now_ms),
})
}
/// Clear all state; ready to decode a fresh pass.
pub fn reset(&mut self) {
self.demod.reset();
self.sync.reset();
self.first_line_ms = None;
}
}
fn now_ms() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or(0)
}