[feat](trx-sstv): decode SSTV pictures
CI / test (pull_request) Successful in 7m40s
CI / lint (pull_request) Failing after 14m36s
CI / frontend (pull_request) Successful in 3m12s
CI / reuse (pull_request) Successful in 3s
CI / test (push) Successful in 7m32s
CI / frontend (push) Failing after 1m21s
CI / reuse (push) Successful in 2s
CI / lint (push) Successful in 2m16s

A new decoder crate covering the modes SSTV is actually sent in: Martin
M1/M2, Scottie S1/S2/DX, Robot 36/72, PD50 through PD290, and Wraase
SC2-180.  The mode comes from the VIS header every transmission opens
with, so nothing has to be told what is arriving.

Modes are a table rather than code: a list of segments -- sync, gaps,
and one scan per colour channel -- plus a colour model and a geometry.
The decoder reads the offset of each scan straight off that list, which
is what makes fifteen modes cost about as much as one, and a new mode a
table entry.  The segment lists are checked against the published line
durations in a test, because both are transcribed by hand from the same
specification and a digit wrong in one is unlikely to be wrong
identically in the other.

Signal path: band-pass over the SSTV band, Hilbert FIR, instantaneous
frequency by phase difference, then a state machine that walks the
transmission a line at a time.  Each line is looked for where the mode
says it should be and nudged into place by the sync pulse found near
it -- two sound cards never agree exactly, and over the two minutes of a
Martin M1 frame an uncorrected error of a few parts per million shears
the picture visibly.  Rows are emitted as they decode, so a picture can
be watched arriving, which is most of the appeal of the mode.

Four things this cost, each now the reason a piece of it is shaped the
way it is:

The per-sample frequency estimate ripples by ±95 Hz at 1200 Hz, where
the Hilbert approximation is weakest, though its mean is exact.  Pixels
average over their own window and were always right; the VIS bits and
the sync detector classify individual samples and were reading the
ripple.  Both now read short means.  Pixels deliberately still do not,
so edges stay where they are.

Broadband noise cost the whole picture, not part of it: a
phase-difference detector answers whatever is loudest, and there was no
input filter.  Hence the band-pass, which is what every real decoder
does first.

A sync search window shorter than a sync pulse rejected every pulse
arriving late in it, for being short.

The first line's sync search locked onto the VIS stop bit -- 30 ms at
exactly the sync frequency, immediately before the picture starts.  The
header already says where the picture begins, so the first line no
longer searches.

Tests: nine modes are encoded from a test card and decoded back,
compared pixel by pixel, alongside silence around the signal, a
transmission cut off part way, two transmissions back to back, 20 dB of
noise, and a transmitter clock 0.1% fast.  The encoder that produces
those signals reads the same table as the decoder, so a round trip
tests the decoder and not the timings; the timings are held to the
published line durations separately.

Nothing is wired into the server or the web UI yet: this is the decoder
alone.

Signed-off-by: Stan Grams <sjg@haxx.space>
This commit was merged in pull request #43.
This commit is contained in:
sjg
2026-08-05 23:29:52 +02:00
parent a2c630a92b
commit a0b0c0ed81
13 changed files with 2678 additions and 0 deletions
Generated
+10
View File
@@ -3307,6 +3307,16 @@ dependencies = [
"uuid",
]
[[package]]
name = "trx-sstv"
version = "0.1.0"
dependencies = [
"base64",
"png",
"tracing",
"trx-core",
]
[[package]]
name = "trx-vdes"
version = "0.1.0"
+1
View File
@@ -12,6 +12,7 @@ members = [
"src/decoders/trx-ftx",
"src/decoders/trx-rds",
"src/decoders/trx-vdes",
"src/decoders/trx-sstv",
"src/decoders/trx-wefax",
"src/decoders/trx-wspr",
"src/trx-core",
+14
View File
@@ -0,0 +1,14 @@
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
#
# SPDX-License-Identifier: GPL-2.0-or-later
[package]
name = "trx-sstv"
version.workspace = true
edition = "2021"
[dependencies]
trx-core = { path = "../../trx-core" }
base64 = "0.22"
png = "0.17"
tracing = "0.1"
@@ -0,0 +1,91 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
//! Encode a test card, decode it back, write both as PNGs to a directory.
//!
//! `cargo run -p trx-sstv --example round_trip_png -- /tmp/out`
use trx_sstv::encode::{encode, Frame};
use trx_sstv::mode::mode_for_vis;
use trx_sstv::{ImageCanvas, SstvConfig, SstvDecoder, SstvEvent};
fn main() {
let dir = std::env::args().nth(1).unwrap_or_else(|| ".".into());
for vis in [44u8, 60, 12, 8, 95] {
let mode = mode_for_vis(vis).expect("mode in table");
let (w, h) = (usize::from(mode.width), usize::from(mode.height));
let mut rgb = vec![0u8; w * h * 3];
for y in 0..h {
for x in 0..w {
let at = (y * w + x) * 3;
let (r, g, b) = if y < h / 3 {
[
(255u8, 255u8, 255u8),
(255, 255, 0),
(0, 255, 255),
(0, 255, 0),
(255, 0, 255),
(255, 0, 0),
(0, 0, 255),
(0, 0, 0),
][x * 8 / w]
} else if y < 2 * h / 3 {
let t = (x * 255 / w) as u8;
(t, 255 - t, ((y * 255) / h) as u8)
} else {
// Diagonal stripes: a line-timing error shows up as a kink.
if ((x + y) / 16) % 2 == 0 {
(240, 240, 40)
} else {
(20, 20, 90)
}
};
rgb[at] = r;
rgb[at + 1] = g;
rgb[at + 2] = b;
}
}
let name = mode.name.replace(' ', "-");
let mut sent = ImageCanvas::new(w, h);
for y in 0..h {
sent.put_row(y, &rgb[y * w * 3..(y + 1) * w * 3]);
}
std::fs::write(
format!("{dir}/{name}-sent.png"),
sent.to_png().expect("png"),
)
.expect("write");
let frame = Frame {
width: w,
height: h,
rgb: &rgb,
};
let mut audio = encode(mode, &frame, 48_000);
audio.extend(std::iter::repeat_n(0.0, 4800));
let mut decoder = SstvDecoder::new(48_000, SstvConfig::default());
let mut got = None;
for block in audio.chunks(1024) {
for event in decoder.process_samples(block) {
if let SstvEvent::Complete(image) = event {
got = Some(image);
}
}
}
let image = got.expect("no image decoded");
let mut canvas = ImageCanvas::new(w, h);
for y in 0..h {
canvas.put_row(y, &image.rgb[y * w * 3..(y + 1) * w * 3]);
}
std::fs::write(
format!("{dir}/{name}-decoded.png"),
canvas.to_png().expect("png"),
)
.expect("write");
println!(
"{}: {} lines, complete={}",
mode.name, image.lines, image.complete
);
}
}
+17
View File
@@ -0,0 +1,17 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
//! SSTV decoder configuration.
/// Settings for [`crate::decoder::SstvDecoder`].
#[derive(Debug, Clone, Default)]
pub struct SstvConfig {
/// VIS code of a mode to assume when no header is heard, so tuning in
/// part-way through a transmission still produces a picture. `None` means
/// wait for a header, which is the safe default: guessing wrong yields a
/// convincing image of nothing.
pub force_mode: Option<u8>,
/// Directory for saved PNGs. `None` keeps images in memory only.
pub output_dir: Option<String>,
}
+589
View File
@@ -0,0 +1,589 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
//! The decoder: audio in, pictures out.
//!
//! Reception is a small state machine. It listens for a VIS header, and once
//! one names a mode it walks the transmission a line at a time, sampling each
//! scan at the offsets the mode's segment list gives. Every line is looked for
//! at the time the mode says it should arrive, then nudged into place by the
//! sync pulse actually found near it — a transmitter's clock and a receiver's
//! sound card never agree exactly, and over two minutes of Martin M1 an
//! uncorrected error of a few parts per million visibly shears the picture.
//!
//! Rows are emitted as they are decoded so a picture can be watched arriving,
//! which is most of the appeal of the mode.
use crate::config::SstvConfig;
use crate::demod::FreqDemod;
use crate::image::ImageCanvas;
use crate::mode::{level_from_hz, mode_for_vis, Channel, ColorModel, SstvMode};
use crate::vis::find_vis;
/// Anything below this is the sync pulse rather than picture: black is 1500 Hz
/// and sync is 1200 Hz, so the line sits between them.
const SYNC_THRESHOLD_HZ: f32 = 1350.0;
/// How much of the signal to keep while hunting for a header. A header is 940
/// ms; two seconds leaves room for one to straddle several blocks of audio.
const SEARCH_HISTORY_MS: f64 = 2000.0;
/// From the start bit to the end of the stop bit: ten 30 ms cells. A header
/// search must never retire a stretch shorter than this, or a start bit split
/// across two blocks of audio is dismissed on half a view of it.
const HEADER_SPAN_MS: f64 = 330.0;
/// How far from its predicted position a line's sync pulse is looked for.
const SYNC_SEARCH_MS: f64 = 12.0;
/// Fraction of the observed timing error applied to the next line. Damped,
/// because a sync pulse found in noise is worth less than the prediction.
const SYNC_CORRECTION: f64 = 0.45;
/// Consecutive lines with no sync pulse anywhere near the prediction before
/// the transmission is taken to have ended.
const MISSING_SYNC_LIMIT: u32 = 8;
/// What the decoder has to say.
#[derive(Debug, Clone)]
pub enum SstvEvent {
/// A header was decoded and reception has begun.
Started {
vis: u8,
mode: &'static str,
width: u16,
height: u16,
},
/// One image row is ready, as RGB triples.
Row { line: u16, rgb: Vec<u8> },
/// Reception finished — at the bottom of the frame, or because the signal
/// went away. Carries the picture either way.
Complete(SstvImage),
}
/// A received picture.
#[derive(Debug, Clone)]
pub struct SstvImage {
pub vis: u8,
pub mode: &'static str,
pub width: u16,
pub height: u16,
/// Rows actually received, which is the height only if it ran to the end.
pub lines: u16,
/// Whether the whole frame arrived.
pub complete: bool,
/// RGB triples, `width * height * 3` bytes. Rows never received are grey.
pub rgb: Vec<u8>,
/// When reception started, in milliseconds since the epoch.
pub started_ms: i64,
}
enum State {
/// Listening for a header.
Searching,
Receiving(Box<Reception>),
}
struct Reception {
mode: &'static SstvMode,
canvas: ImageCanvas,
/// Absolute sample index at which the next transmitted line begins.
next_line: f64,
/// Next image row to write.
row: u16,
started_ms: i64,
missing_syncs: u32,
/// Set until the first line has been decoded. A VIS header ends exactly
/// where the picture begins, so the first line is already aligned — and a
/// search would find the header's own 30 ms stop bit, which is at the sync
/// frequency and sits immediately before the picture.
first_line: bool,
/// Robot 36 sends one chroma channel per line and expects the decoder to
/// carry the other over from the line before.
last_chroma_r: Option<Vec<u8>>,
last_chroma_b: Option<Vec<u8>>,
}
pub struct SstvDecoder {
sample_rate: u32,
config: SstvConfig,
demod: FreqDemod,
/// Instantaneous frequency, one entry per audio sample. Delayed by the
/// demodulator's group delay, which is constant and so shifts the whole
/// stream — header and lines alike — without disturbing their spacing.
freqs: Vec<f32>,
/// Absolute index of `freqs[0]`, so positions survive the buffer being
/// trimmed.
base: u64,
/// Absolute index the header search has already covered.
searched_to: u64,
state: State,
}
impl SstvDecoder {
pub fn new(sample_rate: u32, config: SstvConfig) -> Self {
Self {
sample_rate,
config,
demod: FreqDemod::new(sample_rate),
freqs: Vec::new(),
base: 0,
searched_to: 0,
state: State::Searching,
}
}
/// Whether a picture is currently arriving.
pub fn is_receiving(&self) -> bool {
matches!(self.state, State::Receiving(_))
}
/// Feed a block of mono audio. Returns whatever it produced.
pub fn process_samples(&mut self, samples: &[f32]) -> Vec<SstvEvent> {
self.demod.process_into(samples, &mut self.freqs);
let mut events = Vec::new();
loop {
let progressed = match self.state {
State::Searching => self.try_start(&mut events),
State::Receiving(_) => self.try_line(&mut events),
};
if !progressed {
break;
}
}
self.trim();
events
}
/// Abandon a reception in progress, returning the picture so far.
pub fn reset(&mut self) -> Vec<SstvEvent> {
let mut events = Vec::new();
if let State::Receiving(reception) = std::mem::replace(&mut self.state, State::Searching) {
events.push(SstvEvent::Complete(finish(&reception)));
}
self.demod.reset();
self.freqs.clear();
self.base = 0;
self.searched_to = 0;
events
}
/// Look for a header, or for a bare sync pulse when the mode is forced.
fn try_start(&mut self, events: &mut Vec<SstvEvent>) -> bool {
let from = self.searched_to.saturating_sub(self.base) as usize;
if from >= self.freqs.len() {
return false;
}
if let Some(hit) = find_vis(&self.freqs, self.sample_rate, from) {
if let Some(mode) = mode_for_vis(hit.code) {
self.begin(mode, self.base + hit.image_start as u64, events);
return true;
}
// A header that parses but names a mode this decoder does not know
// is still a header: skip past it rather than finding it again.
tracing::debug!(vis = hit.code, "SSTV: unsupported mode");
self.searched_to = self.base + hit.image_start as u64;
return true;
}
// Tuning in mid-transmission means no header to find. With a mode
// named in the configuration, the first sync pulse is enough to start.
if let Some(mode) = self.config.force_mode.and_then(mode_for_vis) {
if let Some(sync) = self.find_sync(from, self.freqs.len(), mode) {
let line_start = (self.base + sync as u64) as f64
- ms_to_samples(mode.sync_offset_ms, self.sample_rate);
self.begin_at(mode, line_start.max(0.0), events);
return true;
}
}
// Nothing yet. Rewind the cursor by a header's worth of samples before
// marking the buffer searched: audio arrives in blocks of a few
// milliseconds, so the search regularly runs over a start bit that is
// only half here. Advancing past it would retire the header for good
// on the strength of a partial view of it.
let unsearchable = ms_to_samples(HEADER_SPAN_MS, self.sample_rate) as u64;
let end = self.base + self.freqs.len() as u64;
self.searched_to = self.searched_to.max(end.saturating_sub(unsearchable));
false
}
fn begin(&mut self, mode: &'static SstvMode, image_start: u64, events: &mut Vec<SstvEvent>) {
self.begin_at(mode, image_start as f64, events);
}
fn begin_at(&mut self, mode: &'static SstvMode, line_start: f64, events: &mut Vec<SstvEvent>) {
events.push(SstvEvent::Started {
vis: mode.vis,
mode: mode.name,
width: mode.width,
height: mode.height,
});
self.state = State::Receiving(Box::new(Reception {
mode,
canvas: ImageCanvas::new(usize::from(mode.width), usize::from(mode.height)),
next_line: line_start,
row: 0,
started_ms: now_ms(),
missing_syncs: 0,
first_line: true,
last_chroma_r: None,
last_chroma_b: None,
}));
}
/// Decode one transmitted line, if all of it has arrived.
fn try_line(&mut self, events: &mut Vec<SstvEvent>) -> bool {
let State::Receiving(reception) = &mut self.state else {
return false;
};
let mode = reception.mode;
let line_samples = ms_to_samples(mode.line_ms, self.sample_rate);
let margin = ms_to_samples(SYNC_SEARCH_MS, self.sample_rate);
let start = reception.next_line;
// Enough for the line's own scans, and for the sync search to reach as
// far ahead as it looks — no further. Demanding the search margin past
// the end of every line would cost the last line of every picture,
// which is exactly where the transmission stops.
let sync_reach = start + ms_to_samples(mode.sync_offset_ms, self.sample_rate) + margin;
let end = (start + line_samples).max(sync_reach);
let available = (self.base + self.freqs.len() as u64) as f64;
if end > available {
return false;
}
// The line may begin before what is still buffered if the caller fed a
// huge block; nothing can be done about that but skip forward.
if start < self.base as f64 {
reception.next_line = self.base as f64;
return true;
}
// Line up on the sync pulse near where this line is predicted to be.
let expected_sync = start + ms_to_samples(mode.sync_offset_ms, self.sample_rate);
let from = (expected_sync - margin).max(self.base as f64) as u64;
let to = (expected_sync + margin) as u64;
let searching = !matches!(&self.state, State::Receiving(r) if r.first_line);
let found = if searching {
self.find_sync_between(from, to, mode)
} else {
None
};
let State::Receiving(reception) = &mut self.state else {
return false;
};
let start = match found {
Some(sync_at) => {
reception.missing_syncs = 0;
let error = sync_at as f64 - expected_sync;
reception.next_line += error * SYNC_CORRECTION;
reception.next_line
}
None if reception.first_line => start,
None => {
reception.missing_syncs += 1;
start
}
};
if reception.missing_syncs >= MISSING_SYNC_LIMIT {
let image = finish(reception);
self.state = State::Searching;
self.searched_to = self.base + self.freqs.len() as u64;
events.push(SstvEvent::Complete(image));
return true;
}
// Sample every scan of the line, then colour the rows.
let mut scans: Vec<(Channel, Vec<u8>)> = Vec::new();
for (channel, offset_ms, ms) in mode.scans() {
let pixels = mode.scan_pixels(channel);
let at = start + ms_to_samples(offset_ms, self.sample_rate);
let values = sample_scan(&self.freqs, self.base, at, ms, pixels, self.sample_rate);
scans.push((channel, values));
}
let State::Receiving(reception) = &mut self.state else {
return false;
};
let rows = compose_rows(reception, &scans);
for (offset, row) in rows.into_iter().enumerate() {
let line = reception.row + offset as u16;
reception.canvas.put_row(usize::from(line), &row);
events.push(SstvEvent::Row { line, rgb: row });
}
reception.first_line = false;
reception.row += mode.lines_per_transmission;
reception.next_line += line_samples;
if reception.row >= mode.height {
let image = finish(reception);
self.state = State::Searching;
self.searched_to = self.base + self.freqs.len() as u64;
events.push(SstvEvent::Complete(image));
}
true
}
/// First sync pulse of about the right length in `freqs[from..to]`,
/// as an index of its leading edge.
///
/// Works on a smoothed copy of the window: a sync pulse is 1200 Hz, where
/// the raw per-sample estimate swings by ±95 Hz, so single samples cross
/// and re-cross the threshold throughout a pulse and no run is ever long
/// enough. Pixels are sampled from the raw signal, where averaging over
/// the pixel does the same job without blurring across its edges.
fn find_sync(&self, from: usize, to: usize, mode: &SstvMode) -> Option<usize> {
let want = sync_ms(mode);
let min_run = (ms_to_samples(want, self.sample_rate) * 0.6) as usize;
// A sync pulse ends. Silence and a dead carrier demodulate to near
// zero, which is below the threshold too, and without an upper bound a
// decoder left running on an empty channel finds sync everywhere and
// fills the picture with noise it invented.
let max_run = (ms_to_samples(want, self.sample_rate) * 3.0) as usize;
let to = to.min(self.freqs.len());
if from >= to {
return None;
}
// Reach past the end of the search window by a whole pulse: a sync
// starting at the last moment the window allows still has to be
// measurable to its full length, or it is rejected for being short and
// the line it belongs to goes unaligned.
let pad = ms_to_samples(want + 2.0, self.sample_rate) as usize;
let window_from = from.saturating_sub(pad);
let window_to = (to + pad).min(self.freqs.len());
let smoothed = crate::demod::smooth(
&self.freqs[window_from..window_to],
ms_to_samples(1.0, self.sample_rate) as usize,
);
let mut i = from - window_from;
let scan_to = to - window_from;
while i < scan_to {
if smoothed[i] >= SYNC_THRESHOLD_HZ {
i += 1;
continue;
}
let mut run = 0;
while i + run < smoothed.len() && smoothed[i + run] < SYNC_THRESHOLD_HZ {
run += 1;
}
if run >= min_run && run <= max_run {
return Some(window_from + i);
}
i += run.max(1);
}
None
}
/// As [`Self::find_sync`], over an absolute index range.
fn find_sync_between(&self, from: u64, to: u64, mode: &SstvMode) -> Option<u64> {
let from = from.saturating_sub(self.base) as usize;
let to = to.saturating_sub(self.base) as usize;
if from >= self.freqs.len() {
return None;
}
self.find_sync(from, to, mode)
.map(|at| self.base + at as u64)
}
/// Drop what is behind the decoder, so a long reception does not grow the
/// buffer without bound.
fn trim(&mut self) {
let keep_from = match &self.state {
State::Searching => {
let history = ms_to_samples(SEARCH_HISTORY_MS, self.sample_rate) as u64;
(self.base + self.freqs.len() as u64).saturating_sub(history)
}
State::Receiving(reception) => {
let margin = ms_to_samples(SYNC_SEARCH_MS * 2.0, self.sample_rate) as u64;
(reception.next_line as u64).saturating_sub(margin)
}
};
if keep_from <= self.base {
return;
}
let drop = (keep_from - self.base) as usize;
if drop >= self.freqs.len() {
self.freqs.clear();
} else {
self.freqs.drain(..drop);
}
self.base = keep_from;
self.searched_to = self.searched_to.max(self.base);
}
}
/// The sync pulse length of a mode, read out of its own segment list.
fn sync_ms(mode: &SstvMode) -> f64 {
mode.segments
.iter()
.find_map(|segment| match segment {
crate::mode::Segment::Sync(ms) => Some(*ms),
_ => None,
})
.unwrap_or(9.0)
}
/// Milliseconds since the epoch, for stamping a picture with when it arrived.
fn now_ms() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as i64
}
fn ms_to_samples(ms: f64, sample_rate: u32) -> f64 {
ms / 1000.0 * f64::from(sample_rate)
}
/// Average the frequency across each pixel's window and turn it into a level.
///
/// The middle 60% of the window is used: a pixel's edges carry the
/// demodulator's transition from the pixel before, and including them smears
/// every edge in the picture.
fn sample_scan(
freqs: &[f32],
base: u64,
start: f64,
ms: f64,
pixels: usize,
sample_rate: u32,
) -> Vec<u8> {
let mut out = Vec::with_capacity(pixels);
let width = ms_to_samples(ms, sample_rate) / pixels as f64;
for x in 0..pixels {
let pixel_start = start + width * x as f64;
let from = (pixel_start + width * 0.2 - base as f64).max(0.0) as usize;
let to = ((pixel_start + width * 0.8 - base as f64).max(0.0) as usize).min(freqs.len());
// A pixel narrower than a sample still has to produce one.
let (from, to) = if to > from {
(from, to)
} else {
let at = (pixel_start - base as f64).max(0.0) as usize;
(
at.min(freqs.len().saturating_sub(1)),
(at + 1).min(freqs.len()),
)
};
if to <= from {
out.push(0);
continue;
}
let window = &freqs[from..to];
let mean = window.iter().sum::<f32>() / window.len() as f32;
out.push(level_from_hz(mean));
}
out
}
/// Turn one line's scans into image rows.
fn compose_rows(reception: &mut Reception, scans: &[(Channel, Vec<u8>)]) -> Vec<Vec<u8>> {
let mode = reception.mode;
let width = usize::from(mode.width);
let find = |channel: Channel| scans.iter().find(|(c, _)| *c == channel).map(|(_, v)| v);
match mode.color {
ColorModel::Rgb => {
let red = find(Channel::Red);
let green = find(Channel::Green);
let blue = find(Channel::Blue);
let mut row = vec![0u8; width * 3];
for x in 0..width {
row[x * 3] = red.and_then(|c| c.get(x).copied()).unwrap_or(0);
row[x * 3 + 1] = green.and_then(|c| c.get(x).copied()).unwrap_or(0);
row[x * 3 + 2] = blue.and_then(|c| c.get(x).copied()).unwrap_or(0);
}
vec![row]
}
ColorModel::YCrCb => {
let luma = find(Channel::LumaOdd).cloned().unwrap_or_default();
let cr = find(Channel::ChromaR).cloned().unwrap_or_default();
let cb = find(Channel::ChromaB).cloned().unwrap_or_default();
vec![ycrcb_row(&luma, &cr, &cb, width)]
}
ColorModel::YCrCbAlternating => {
// This line carries one chroma channel; the other is the one from
// the line before, which is what the mode expects a decoder to do.
let luma = find(Channel::LumaOdd).cloned().unwrap_or_default();
let chroma = find(Channel::ChromaAlternating)
.cloned()
.unwrap_or_default();
if reception.row.is_multiple_of(2) {
reception.last_chroma_r = Some(chroma);
} else {
reception.last_chroma_b = Some(chroma);
}
let neutral = vec![128u8; luma.len().max(1) / 2];
let cr = reception
.last_chroma_r
.clone()
.unwrap_or_else(|| neutral.clone());
let cb = reception.last_chroma_b.clone().unwrap_or(neutral);
vec![ycrcb_row(&luma, &cr, &cb, width)]
}
ColorModel::YCrCbPaired => {
let odd = find(Channel::LumaOdd).cloned().unwrap_or_default();
let even = find(Channel::LumaEven).cloned().unwrap_or_default();
let cr = find(Channel::ChromaR).cloned().unwrap_or_default();
let cb = find(Channel::ChromaB).cloned().unwrap_or_default();
vec![
ycrcb_row(&odd, &cr, &cb, width),
ycrcb_row(&even, &cr, &cb, width),
]
}
}
}
/// One RGB row from luminance and chrominance, stretching the chroma scans
/// across the width when they are narrower than it.
fn ycrcb_row(luma: &[u8], cr: &[u8], cb: &[u8], width: usize) -> Vec<u8> {
let mut row = vec![0u8; width * 3];
let pick = |channel: &[u8], x: usize| -> f32 {
if channel.is_empty() {
return 128.0;
}
let at = x * channel.len() / width.max(1);
f32::from(channel[at.min(channel.len() - 1)])
};
for x in 0..width {
let y = if luma.is_empty() {
0.0
} else {
let at = x * luma.len() / width.max(1);
f32::from(luma[at.min(luma.len() - 1)])
};
let (r, g, b) = ycrcb_to_rgb(y, pick(cr, x), pick(cb, x));
row[x * 3] = r;
row[x * 3 + 1] = g;
row[x * 3 + 2] = b;
}
row
}
/// The inverse of the studio-swing conversion SSTV encoders use.
fn ycrcb_to_rgb(y: f32, cr: f32, cb: f32) -> (u8, u8, u8) {
let r = 298.082 * y / 256.0 + 408.583 * cr / 256.0 - 222.921;
let g = 298.082 * y / 256.0 - 100.291 * cb / 256.0 - 208.120 * cr / 256.0 + 135.576;
let b = 298.082 * y / 256.0 + 516.412 * cb / 256.0 - 276.836;
(
r.clamp(0.0, 255.0) as u8,
g.clamp(0.0, 255.0) as u8,
b.clamp(0.0, 255.0) as u8,
)
}
fn finish(reception: &Reception) -> SstvImage {
SstvImage {
vis: reception.mode.vis,
mode: reception.mode.name,
width: reception.mode.width,
height: reception.mode.height,
lines: reception.canvas.filled_rows() as u16,
complete: reception.row >= reception.mode.height,
rgb: reception.canvas.rgb().to_vec(),
started_ms: reception.started_ms,
}
}
+297
View File
@@ -0,0 +1,297 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
//! Instantaneous frequency estimation.
//!
//! SSTV carries every pixel as a frequency between 1500 and 2300 Hz, and every
//! line boundary as a 1200 Hz pulse, so one measurement serves the whole
//! decoder: the frequency of the signal at each sample. A Hilbert transform
//! FIR forms the analytic signal and the phase difference between consecutive
//! samples gives the frequency.
//!
//! The same approach drives the WEFAX decoder, which maps the result straight
//! to luminance. Here the frequency itself is the output, because the VIS
//! header and the sync detector read tones far outside the pixel band.
//!
//! Block-based linear processing, per `docs/Optimization-Guidelines.md`: the
//! FIR runs over a contiguous `[tail | samples]` buffer so the inner loop is
//! straight indexing the compiler can vectorise.
use std::f32::consts::PI;
/// Taps for the Hilbert transform FIR. Odd, so the delay is a whole sample.
const HILBERT_TAPS: usize = 65;
/// Group delay of the FIR, in samples.
const HILBERT_DELAY: usize = HILBERT_TAPS / 2;
/// Taps for the input band-pass. Long enough to be worth having, short enough
/// that its delay is a couple of milliseconds.
const BANDPASS_TAPS: usize = 127;
/// The band SSTV lives in: sync at 1200 Hz, black at 1500, white at 2300.
const BAND_LOW_HZ: f32 = 900.0;
const BAND_HIGH_HZ: f32 = 2700.0;
/// Produces instantaneous frequency in Hz from real audio samples.
pub struct FreqDemod {
/// Band-pass, applied first. A phase-difference frequency detector answers
/// whatever is loudest, so hiss outside the SSTV band steers the estimate
/// even when the signal is much stronger — the picture tears rather than
/// grows grainy. Every real decoder filters to the band first.
bandpass: Vec<f32>,
bandpass_tail: Vec<f32>,
coeffs: [f32; HILBERT_TAPS],
/// The last `HILBERT_TAPS - 1` input samples, priming the next block.
tail: Vec<f32>,
prev_i: f32,
prev_q: f32,
/// `sample_rate / 2π`, the constant turning phase step into Hz.
hz_per_radian: f32,
}
impl FreqDemod {
pub fn new(sample_rate: u32) -> Self {
Self {
bandpass: design_bandpass_fir(sample_rate),
bandpass_tail: vec![0.0; BANDPASS_TAPS - 1],
coeffs: design_hilbert_fir(),
tail: vec![0.0; HILBERT_TAPS - 1],
prev_i: 0.0,
prev_q: 0.0,
hz_per_radian: sample_rate as f32 / (2.0 * PI),
}
}
/// Band-pass a block, carrying the filter's state across the seam.
fn filter(&mut self, samples: &[f32]) -> Vec<f32> {
let taps = BANDPASS_TAPS;
let tail_len = taps - 1;
let mut work = Vec::with_capacity(tail_len + samples.len());
work.extend_from_slice(&self.bandpass_tail);
work.extend_from_slice(samples);
let mut out = Vec::with_capacity(samples.len());
for i in 0..samples.len() {
let window = &work[i..i + taps];
let mut acc = 0.0f32;
for k in 0..taps {
acc += self.bandpass[k] * window[taps - 1 - k];
}
out.push(acc);
}
let work_len = work.len();
self.bandpass_tail
.copy_from_slice(&work[work_len - tail_len..]);
out
}
/// Frequency in Hz for each input sample, appended to `out`.
///
/// Output is delayed by the FIR's group delay, which is constant and so
/// affects only the absolute timing of the whole stream, not the spacing
/// between the events in it.
pub fn process_into(&mut self, samples: &[f32], out: &mut Vec<f32>) {
if samples.is_empty() {
return;
}
let samples = self.filter(samples);
let samples = samples.as_slice();
let taps = HILBERT_TAPS;
let tail_len = taps - 1;
let mut work = Vec::with_capacity(tail_len + samples.len());
work.extend_from_slice(&self.tail);
work.extend_from_slice(samples);
out.reserve(samples.len());
for i in 0..samples.len() {
let window = &work[i..i + taps];
let mut q = 0.0f32;
for k in 0..taps {
q += self.coeffs[k] * window[taps - 1 - k];
}
// In phase with the quadrature output: the input, delayed by the
// FIR's own group delay.
let i_val = window[HILBERT_DELAY];
// f = |arg(z[n] · conj(z[n-1]))| · fs / 2π
let di = i_val * self.prev_i + q * self.prev_q;
let dq = q * self.prev_i - i_val * self.prev_q;
out.push(dq.atan2(di).abs() * self.hz_per_radian);
self.prev_i = i_val;
self.prev_q = q;
}
let work_len = work.len();
self.tail.copy_from_slice(&work[work_len - tail_len..]);
}
pub fn reset(&mut self) {
self.bandpass_tail.fill(0.0);
self.tail.fill(0.0);
self.prev_i = 0.0;
self.prev_q = 0.0;
}
}
/// Boxcar mean over `window` samples, centred, returning one value per input.
///
/// The per-sample estimate ripples — badly at the low end of the band, where
/// the Hilbert approximation is weakest: a clean 1200 Hz tone reads anywhere
/// between 1110 and 1300 Hz sample to sample, though its mean is exact. Pixels
/// are averaged over their own window and so come out right regardless, but
/// anything that classifies a single sample by frequency — the VIS bits, the
/// sync pulses — has to look at a mean or it is reading the ripple.
pub fn smooth(freqs: &[f32], window: usize) -> Vec<f32> {
let window = window.max(1);
if freqs.is_empty() {
return Vec::new();
}
// Prefix sums in f64: a minute of audio is three million samples, and a
// running f32 total drifts long before that.
let mut prefix = Vec::with_capacity(freqs.len() + 1);
prefix.push(0.0f64);
for &freq in freqs {
prefix.push(prefix[prefix.len() - 1] + f64::from(freq));
}
let half = window / 2;
(0..freqs.len())
.map(|i| {
// Shrinks at the ends rather than reaching past them.
let from = i.saturating_sub(half);
let to = (i + window - half).min(freqs.len());
((prefix[to] - prefix[from]) / (to - from) as f64) as f32
})
.collect()
}
/// Windowed-sinc band-pass over the SSTV band, Hamming-windowed and
/// linear-phase, so every frequency in the band is delayed alike.
fn design_bandpass_fir(sample_rate: u32) -> Vec<f32> {
let sr = sample_rate as f64;
let low = f64::from(BAND_LOW_HZ) / sr;
let high = (f64::from(BAND_HIGH_HZ) / sr).min(0.499);
let m = (BANDPASS_TAPS - 1) as f64;
let mid = m / 2.0;
let sinc = |x: f64| {
if x.abs() < 1e-9 {
1.0
} else {
(std::f64::consts::PI * x).sin() / (std::f64::consts::PI * x)
}
};
let mut coeffs = Vec::with_capacity(BANDPASS_TAPS);
for i in 0..BANDPASS_TAPS {
let n = i as f64 - mid;
// Difference of two low-passes is a band-pass.
let ideal = 2.0 * high * sinc(2.0 * high * n) - 2.0 * low * sinc(2.0 * low * n);
let window = 0.54 - 0.46 * (2.0 * std::f64::consts::PI * i as f64 / m).cos();
coeffs.push((ideal * window) as f32);
}
coeffs
}
/// Type III FIR approximating a 90° phase shift: h[n] = 2/(πn) for odd n,
/// Blackman-windowed. Independent of sample rate, so the decoder can run at
/// whatever rate the audio arrives in.
fn design_hilbert_fir() -> [f32; HILBERT_TAPS] {
let mut coeffs = [0.0f32; HILBERT_TAPS];
let m = (HILBERT_TAPS - 1) as f64;
let mid = m / 2.0;
for (i, coeff) in coeffs.iter_mut().enumerate() {
let n = i as f64 - mid;
let ni = n.round() as i64;
if ni != 0 && ni % 2 != 0 {
let h = 2.0 / (std::f64::consts::PI * n);
let w = 0.42 - 0.5 * (2.0 * std::f64::consts::PI * i as f64 / m).cos()
+ 0.08 * (4.0 * std::f64::consts::PI * i as f64 / m).cos();
*coeff = (h * w) as f32;
}
}
coeffs
}
#[cfg(test)]
mod tests {
use super::*;
fn tone(freq: f32, sample_rate: u32, samples: usize) -> Vec<f32> {
(0..samples)
.map(|n| (2.0 * PI * freq * n as f32 / sample_rate as f32).sin())
.collect()
}
/// Measured on the settled part of the output: the first `HILBERT_TAPS`
/// samples are the filter filling up.
fn measure(freq: f32, sample_rate: u32) -> f32 {
let mut demod = FreqDemod::new(sample_rate);
let mut out = Vec::new();
demod.process_into(
&tone(freq, sample_rate, sample_rate as usize / 10),
&mut out,
);
let settled = &out[HILBERT_TAPS * 2..];
settled.iter().sum::<f32>() / settled.len() as f32
}
#[test]
fn reads_the_tones_sstv_is_made_of() {
for rate in [8000u32, 11025, 44100, 48000] {
for freq in [1200.0f32, 1500.0, 1900.0, 2300.0] {
let measured = measure(freq, rate);
assert!(
(measured - freq).abs() < 5.0,
"{rate} Hz: {freq} Hz tone measured as {measured:.1} Hz",
);
}
}
}
/// Blocks are whatever size the audio pipeline hands over, and a tone that
/// straddles two of them must not produce a discontinuity at the seam.
#[test]
fn block_boundaries_do_not_disturb_the_estimate() {
let rate = 48_000;
let samples = tone(1900.0, rate, 9600);
let mut whole = FreqDemod::new(rate);
let mut expected = Vec::new();
whole.process_into(&samples, &mut expected);
let mut split = FreqDemod::new(rate);
let mut actual = Vec::new();
for chunk in samples.chunks(137) {
split.process_into(chunk, &mut actual);
}
assert_eq!(actual.len(), expected.len());
for (i, (a, b)) in actual.iter().zip(&expected).enumerate() {
assert!((a - b).abs() < 0.01, "sample {i}: {a} vs {b}");
}
}
#[test]
fn follows_a_step_between_tones_within_a_pixel() {
let rate = 48_000;
let mut samples = tone(1500.0, rate, 4800);
samples.extend(tone(2300.0, rate, 4800));
let mut demod = FreqDemod::new(rate);
let mut out = Vec::new();
demod.process_into(&samples, &mut out);
// Well before the step it reads black; well after it, white. The step
// itself takes the filter's length to pass through.
let before = out[4800 - 200..4800 - 100].iter().sum::<f32>() / 100.0;
let after = out[4800 + 200..4800 + 300].iter().sum::<f32>() / 100.0;
assert!(
(before - 1500.0).abs() < 10.0,
"before the step: {before:.1} Hz"
);
assert!(
(after - 2300.0).abs() < 10.0,
"after the step: {after:.1} Hz"
);
}
}
+238
View File
@@ -0,0 +1,238 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
//! Turning an image into an SSTV signal.
//!
//! This exists so the decoder can be held to a picture rather than to a
//! description of one: the tests encode a known image, decode the audio back
//! and compare. Nothing in the receive path uses it.
//!
//! It is written to the same mode table the decoder reads, which makes a
//! round-trip a test of the decoder and not of the timings — the timings are
//! checked separately, against the published line durations, in [`crate::mode`].
use crate::mode::{Channel, Segment, SstvMode, BLACK_HZ, SYNC_HZ, WHITE_HZ};
/// A stretch of constant tone.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Tone {
pub hz: f32,
pub ms: f64,
}
/// Frequency for an 8-bit level: the inverse of [`crate::mode::level_from_hz`].
pub fn hz_from_level(level: u8) -> f32 {
BLACK_HZ + (WHITE_HZ - BLACK_HZ) * f32::from(level) / 255.0
}
/// The tones of a VIS header announcing `vis`.
pub fn vis_tones(vis: u8) -> Vec<Tone> {
let mut tones = vec![
Tone {
hz: 1900.0,
ms: 300.0,
},
Tone {
hz: 1200.0,
ms: 10.0,
},
Tone {
hz: 1900.0,
ms: 300.0,
},
Tone {
hz: 1200.0,
ms: 30.0,
}, // start bit
];
let mut ones = 0;
for bit in 0..7 {
let set = vis & (1 << bit) != 0;
if set {
ones += 1;
}
tones.push(Tone {
hz: if set { 1100.0 } else { 1300.0 },
ms: 30.0,
});
}
// Even parity over the seven data bits.
tones.push(Tone {
hz: if ones % 2 == 1 { 1100.0 } else { 1300.0 },
ms: 30.0,
});
tones.push(Tone {
hz: 1200.0,
ms: 30.0,
}); // stop bit
tones
}
/// RGB source for the encoder: `width * height * 3` bytes.
pub struct Frame<'a> {
pub width: usize,
pub height: usize,
pub rgb: &'a [u8],
}
impl Frame<'_> {
fn pixel(&self, x: usize, y: usize) -> (u8, u8, u8) {
let x = x.min(self.width.saturating_sub(1));
let y = y.min(self.height.saturating_sub(1));
let at = (y * self.width + x) * 3;
(self.rgb[at], self.rgb[at + 1], self.rgb[at + 2])
}
/// The colour components SSTV actually sends, for a pixel.
fn ycrcb(&self, x: usize, y: usize) -> (u8, u8, u8) {
let (r, g, b) = self.pixel(x, y);
let (r, g, b) = (f32::from(r), f32::from(g), f32::from(b));
let y_val = 16.0 + (0.003_906 * ((65.738 * r) + (129.057 * g) + (25.064 * b)));
let cr = 128.0 + (0.003_906 * ((112.439 * r) + (-94.154 * g) + (-18.285 * b)));
let cb = 128.0 + (0.003_906 * ((-37.945 * r) + (-74.494 * g) + (112.439 * b)));
(
y_val.clamp(0.0, 255.0) as u8,
cr.clamp(0.0, 255.0) as u8,
cb.clamp(0.0, 255.0) as u8,
)
}
}
/// Encode `frame` in `mode`, returning the tones of the whole transmission.
pub fn encode_tones(mode: &SstvMode, frame: &Frame<'_>) -> Vec<Tone> {
let mut tones = vis_tones(mode.vis);
let per_line = usize::from(mode.lines_per_transmission);
let transmissions = usize::from(mode.height) / per_line;
for transmission in 0..transmissions {
let top = transmission * per_line;
for segment in mode.segments {
match *segment {
Segment::Sync(ms) => tones.push(Tone { hz: SYNC_HZ, ms }),
Segment::Gap(ms) => tones.push(Tone { hz: BLACK_HZ, ms }),
Segment::Scan { channel, ms } => {
// A chroma scan carries fewer pixels than the image is
// wide, and takes proportionally less time per pixel.
let pixels = mode.scan_pixels(channel);
let pixel_ms = ms / pixels as f64;
for x in 0..pixels {
let level = channel_level(mode, frame, channel, x, top, transmission);
tones.push(Tone {
hz: hz_from_level(level),
ms: pixel_ms,
});
}
}
}
}
}
tones
}
fn channel_level(
mode: &SstvMode,
frame: &Frame<'_>,
channel: Channel,
x: usize,
top: usize,
transmission: usize,
) -> u8 {
match channel {
Channel::Red => frame.pixel(x, top).0,
Channel::Green => frame.pixel(x, top).1,
Channel::Blue => frame.pixel(x, top).2,
Channel::LumaOdd => frame.ycrcb(x, top).0,
Channel::LumaEven => frame.ycrcb(x, top + 1).0,
Channel::ChromaR => chroma(mode, frame, x, top, true),
Channel::ChromaB => chroma(mode, frame, x, top, false),
// Robot 36 sends R-Y on odd transmitted lines and B-Y on even ones.
Channel::ChromaAlternating => chroma(mode, frame, x, top, transmission.is_multiple_of(2)),
}
}
/// Chroma scans are half the width of the image in the Robot modes, so each
/// value covers two pixels; PD averages the two image lines of the pair too.
fn chroma(mode: &SstvMode, frame: &Frame<'_>, x: usize, top: usize, want_cr: bool) -> u8 {
let scale = usize::from(mode.width) / mode.scan_pixels(Channel::ChromaR).max(1);
let x0 = x * scale;
let mut total = 0u32;
let mut count = 0u32;
let rows = usize::from(mode.lines_per_transmission);
for row in 0..rows {
for dx in 0..scale {
let (_, cr, cb) = frame.ycrcb(x0 + dx, top + row);
total += u32::from(if want_cr { cr } else { cb });
count += 1;
}
}
(total / count.max(1)) as u8
}
/// Render tones to audio at `sample_rate`, with continuous phase so the
/// demodulator sees no step at a tone boundary that isn't in the signal.
pub fn render(tones: &[Tone], sample_rate: u32) -> Vec<f32> {
let sr = f64::from(sample_rate);
let mut out =
Vec::with_capacity((tones.iter().map(|t| t.ms).sum::<f64>() / 1000.0 * sr) as usize);
let mut phase = 0.0f64;
// Each tone's *end* is rounded to a sample, rather than its length: a
// pixel of 25.5 samples rounded up on its own puts a whole line 600
// samples late by the end of it, which is a timing error no receiver
// should have to chase and no transmitter would produce.
let mut elapsed_ms = 0.0f64;
let mut emitted = 0usize;
for tone in tones {
elapsed_ms += tone.ms;
let end = (elapsed_ms / 1000.0 * sr).round() as usize;
let samples = end.saturating_sub(emitted);
emitted = end;
let step = 2.0 * std::f64::consts::PI * f64::from(tone.hz) / sr;
for _ in 0..samples {
out.push(phase.sin() as f32);
phase += step;
if phase > std::f64::consts::TAU {
phase -= std::f64::consts::TAU;
}
}
}
out
}
/// Encode a frame straight to audio.
pub fn encode(mode: &SstvMode, frame: &Frame<'_>, sample_rate: u32) -> Vec<f32> {
render(&encode_tones(mode, frame), sample_rate)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::mode::{level_from_hz, mode_for_vis};
#[test]
fn levels_survive_the_trip_through_frequency() {
for level in [0u8, 1, 64, 128, 200, 255] {
assert_eq!(level_from_hz(hz_from_level(level)), level);
}
}
#[test]
fn a_transmission_lasts_as_long_as_the_mode_says() {
let mode = mode_for_vis(44).expect("Martin M1");
let rgb = vec![128u8; 320 * 256 * 3];
let frame = Frame {
width: 320,
height: 256,
rgb: &rgb,
};
let audio = encode(mode, &frame, 48_000);
// Header plus 256 lines, within a line of the published duration.
let header_s = 0.94;
let expected = header_s + mode.frame_secs();
let actual = audio.len() as f64 / 48_000.0;
assert!(
(actual - expected).abs() < mode.line_ms / 1000.0,
"encoded {actual:.2} s, expected {expected:.2} s",
);
}
}
+150
View File
@@ -0,0 +1,150 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
//! Assembling decoded lines into an image, and getting it out of the process.
//!
//! Rows arrive one at a time and the picture is worth looking at before it is
//! finished, so the assembler holds a full-size RGB canvas from the start and
//! fills it in. An unfinished frame is grey below the last decoded row rather
//! than black, which reads as "not here yet" instead of "received as black".
use std::path::{Path, PathBuf};
use base64::Engine;
/// Value the canvas starts at: mid-grey, for rows not yet received.
const UNWRITTEN: u8 = 96;
pub struct ImageCanvas {
width: usize,
height: usize,
rgb: Vec<u8>,
/// Highest row index written, plus one.
filled_rows: usize,
}
impl ImageCanvas {
pub fn new(width: usize, height: usize) -> Self {
Self {
width,
height,
rgb: vec![UNWRITTEN; width * height * 3],
filled_rows: 0,
}
}
pub fn width(&self) -> usize {
self.width
}
pub fn height(&self) -> usize {
self.height
}
/// Rows written so far.
pub fn filled_rows(&self) -> usize {
self.filled_rows
}
/// Write one row of RGB triples. Rows past the bottom of the image are
/// dropped: a transmission that runs long is not a reason to grow.
pub fn put_row(&mut self, y: usize, row: &[u8]) {
if y >= self.height {
return;
}
let at = y * self.width * 3;
let take = row.len().min(self.width * 3);
self.rgb[at..at + take].copy_from_slice(&row[..take]);
self.filled_rows = self.filled_rows.max(y + 1);
}
pub fn row(&self, y: usize) -> Option<&[u8]> {
if y >= self.height {
return None;
}
let at = y * self.width * 3;
Some(&self.rgb[at..at + self.width * 3])
}
pub fn rgb(&self) -> &[u8] {
&self.rgb
}
/// Encode the canvas as a PNG.
pub fn to_png(&self) -> Result<Vec<u8>, String> {
let mut out = Vec::new();
{
let mut encoder = png::Encoder::new(&mut out, self.width as u32, self.height as u32);
encoder.set_color(png::ColorType::Rgb);
encoder.set_depth(png::BitDepth::Eight);
let mut writer = encoder
.write_header()
.map_err(|e| format!("PNG header: {e}"))?;
writer
.write_image_data(&self.rgb)
.map_err(|e| format!("PNG data: {e}"))?;
}
Ok(out)
}
/// The PNG, base64-encoded for the journey to a browser.
pub fn to_png_base64(&self) -> Result<String, String> {
Ok(base64::engine::general_purpose::STANDARD.encode(self.to_png()?))
}
/// Write the PNG into `dir`, named for when and where it was received.
pub fn save_png(
&self,
dir: &Path,
freq_hz: u64,
mode_name: &str,
stamp: &str,
) -> Result<PathBuf, String> {
std::fs::create_dir_all(dir).map_err(|e| format!("create {}: {e}", dir.display()))?;
let slug: String = mode_name
.chars()
.map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
.collect();
let path = dir.join(format!("SSTV_{stamp}_{freq_hz}_{slug}.png"));
std::fs::write(&path, self.to_png()?)
.map_err(|e| format!("write {}: {e}", path.display()))?;
Ok(path)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rows_land_where_they_are_put_and_the_rest_stays_unwritten() {
let mut canvas = ImageCanvas::new(4, 3);
canvas.put_row(1, &[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]);
assert_eq!(canvas.row(1).unwrap()[0..3], [1, 2, 3]);
assert_eq!(canvas.row(0).unwrap()[0], UNWRITTEN);
assert_eq!(canvas.filled_rows(), 2);
}
#[test]
fn a_row_past_the_bottom_is_dropped_rather_than_growing_the_image() {
let mut canvas = ImageCanvas::new(2, 2);
canvas.put_row(9, &[1, 2, 3, 4, 5, 6]);
assert_eq!(canvas.filled_rows(), 0);
assert_eq!(canvas.rgb().len(), 2 * 2 * 3);
}
#[test]
fn encodes_a_png_a_decoder_can_read_back() {
let mut canvas = ImageCanvas::new(2, 2);
canvas.put_row(0, &[255, 0, 0, 0, 255, 0]);
let png_bytes = canvas.to_png().expect("png");
let decoder = png::Decoder::new(png_bytes.as_slice());
let mut reader = decoder.read_info().expect("png info");
let mut buf = vec![0; reader.output_buffer_size()];
let info = reader.next_frame(&mut buf).expect("png frame");
assert_eq!((info.width, info.height), (2, 2));
assert_eq!(&buf[0..6], &[255, 0, 0, 0, 255, 0]);
assert!(!canvas.to_png_base64().expect("base64").is_empty());
}
}
+36
View File
@@ -0,0 +1,36 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
//! SSTV (Slow-Scan Television) decoder.
//!
//! Pure Rust, covering Martin, Scottie, Robot, PD and Wraase SC2-180, with the
//! mode taken from the VIS header that precedes every transmission. Rows are
//! emitted as they arrive so a picture can be watched building up.
//!
//! ```no_run
//! use trx_sstv::{SstvConfig, SstvDecoder, SstvEvent};
//!
//! let mut decoder = SstvDecoder::new(48_000, SstvConfig::default());
//! # let audio: Vec<f32> = Vec::new();
//! for event in decoder.process_samples(&audio) {
//! match event {
//! SstvEvent::Started { mode, .. } => println!("receiving {mode}"),
//! SstvEvent::Row { line, .. } => println!("line {line}"),
//! SstvEvent::Complete(image) => println!("{} lines", image.lines),
//! }
//! }
//! ```
pub mod config;
pub mod decoder;
pub mod demod;
pub mod encode;
pub mod image;
pub mod mode;
pub mod vis;
pub use config::SstvConfig;
pub use decoder::{SstvDecoder, SstvEvent, SstvImage};
pub use image::ImageCanvas;
pub use mode::{mode_for_vis, SstvMode, MODES};
+615
View File
@@ -0,0 +1,615 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
//! SSTV mode table: what a VIS code means in timings, geometry and colour.
//!
//! Every mode transmits a line as a sequence of *segments* — a sync pulse, a
//! porch or separator at a fixed tone, and one scan per colour channel. The
//! decoder needs only the segment layout and the offset of each scan within
//! the line, so that is what a [`SstvMode`] is: a list of segments plus the
//! rules for turning the scans back into pixels.
//!
//! Timings follow the published mode specifications (JL Barber, N7CXI,
//! "Proposal for SSTV Mode Specifications", 2000), which is the same table
//! MMSSTV, QSSTV and slowrx work from.
/// Tone that marks a line boundary, in Hz.
pub const SYNC_HZ: f32 = 1200.0;
/// Tone for black, in Hz.
pub const BLACK_HZ: f32 = 1500.0;
/// Tone for white, in Hz.
pub const WHITE_HZ: f32 = 2300.0;
/// What a scan segment carries.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Channel {
Red,
Green,
Blue,
/// Luminance for the odd (first) image line of the pair.
LumaOdd,
/// Luminance for the even (second) image line of a PD pair.
LumaEven,
/// R-Y chrominance.
ChromaR,
/// B-Y chrominance.
ChromaB,
/// Robot 36 alternates R-Y and B-Y between transmitted lines: odd lines
/// carry R-Y, even lines B-Y, and each is held over both.
ChromaAlternating,
}
/// One piece of a transmitted line.
#[derive(Debug, Clone, Copy)]
pub enum Segment {
/// Sync pulse at [`SYNC_HZ`].
Sync(f64),
/// Porch, separator or gap at a fixed tone; the tone itself is not decoded.
Gap(f64),
/// A scan carrying pixels for one channel.
Scan { channel: Channel, ms: f64 },
}
impl Segment {
pub fn duration_ms(&self) -> f64 {
match *self {
Segment::Sync(ms) | Segment::Gap(ms) => ms,
Segment::Scan { ms, .. } => ms,
}
}
}
/// How the scans of a line become pixels.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ColorModel {
/// Scans are red, green and blue directly (Martin, Scottie, Wraase).
Rgb,
/// Y plus one alternating chroma channel per line (Robot 36).
YCrCbAlternating,
/// Y, R-Y and B-Y in every line (Robot 72).
YCrCb,
/// Two image lines per transmitted line: Y odd, R-Y, B-Y, Y even (PD).
YCrCbPaired,
}
/// A decodable SSTV mode.
#[derive(Debug, Clone)]
pub struct SstvMode {
/// VIS code as sent in the header.
pub vis: u8,
/// Human-readable name, e.g. "Martin M1".
pub name: &'static str,
/// Pixels across.
pub width: u16,
/// Image lines in a full frame.
pub height: u16,
/// Transmitted line duration in milliseconds.
pub line_ms: f64,
/// Segments in transmission order.
pub segments: &'static [Segment],
pub color: ColorModel,
/// Image lines produced by one transmitted line (2 for PD, otherwise 1).
pub lines_per_transmission: u16,
/// Offset from the start of a line to the leading edge of its sync pulse.
/// Zero for most modes; Scottie sends the sync in the middle of the line,
/// so a line detected at its sync starts before it.
pub sync_offset_ms: f64,
}
impl SstvMode {
/// Total of every segment, which must equal [`SstvMode::line_ms`].
pub fn segments_ms(&self) -> f64 {
self.segments.iter().map(Segment::duration_ms).sum()
}
/// Start offset, in milliseconds from the line start, of each scan.
pub fn scans(&self) -> Vec<(Channel, f64, f64)> {
let mut out = Vec::new();
let mut at = 0.0;
for segment in self.segments {
if let Segment::Scan { channel, ms } = *segment {
out.push((channel, at, ms));
}
at += segment.duration_ms();
}
out
}
/// Pixels carried by a scan of this channel.
///
/// Chroma is sent at half the width in the Robot modes — the eye takes
/// colour more coarsely than brightness, and the saving is what makes 36
/// seconds possible. PD sends chroma at full width and saves its time by
/// sharing one pair of chroma scans between two image lines instead.
pub fn scan_pixels(&self, channel: Channel) -> usize {
let width = usize::from(self.width);
match channel {
Channel::ChromaR | Channel::ChromaB | Channel::ChromaAlternating
if self.color != ColorModel::YCrCbPaired =>
{
width / 2
}
_ => width,
}
}
/// Seconds a full frame takes to send.
pub fn frame_secs(&self) -> f64 {
self.line_ms * f64::from(self.height) / f64::from(self.lines_per_transmission) / 1000.0
}
}
// ---------------------------------------------------------------------------
// Martin — sync, porch, then green, blue, red, each followed by a separator.
// ---------------------------------------------------------------------------
const MARTIN_M1: &[Segment] = &[
Segment::Sync(4.862),
Segment::Gap(0.572),
Segment::Scan {
channel: Channel::Green,
ms: 146.432,
},
Segment::Gap(0.572),
Segment::Scan {
channel: Channel::Blue,
ms: 146.432,
},
Segment::Gap(0.572),
Segment::Scan {
channel: Channel::Red,
ms: 146.432,
},
Segment::Gap(0.572),
];
const MARTIN_M2: &[Segment] = &[
Segment::Sync(4.862),
Segment::Gap(0.572),
Segment::Scan {
channel: Channel::Green,
ms: 73.216,
},
Segment::Gap(0.572),
Segment::Scan {
channel: Channel::Blue,
ms: 73.216,
},
Segment::Gap(0.572),
Segment::Scan {
channel: Channel::Red,
ms: 73.216,
},
Segment::Gap(0.572),
];
// ---------------------------------------------------------------------------
// Scottie — the sync pulse sits between the blue and red scans, so a line
// starts one separator before the green scan and the sync of the *previous*
// line is what marks it.
// ---------------------------------------------------------------------------
const SCOTTIE_S1: &[Segment] = &[
Segment::Gap(1.5),
Segment::Scan {
channel: Channel::Green,
ms: 138.240,
},
Segment::Gap(1.5),
Segment::Scan {
channel: Channel::Blue,
ms: 138.240,
},
Segment::Sync(9.0),
Segment::Gap(1.5),
Segment::Scan {
channel: Channel::Red,
ms: 138.240,
},
];
const SCOTTIE_S2: &[Segment] = &[
Segment::Gap(1.5),
Segment::Scan {
channel: Channel::Green,
ms: 88.064,
},
Segment::Gap(1.5),
Segment::Scan {
channel: Channel::Blue,
ms: 88.064,
},
Segment::Sync(9.0),
Segment::Gap(1.5),
Segment::Scan {
channel: Channel::Red,
ms: 88.064,
},
];
const SCOTTIE_DX: &[Segment] = &[
Segment::Gap(1.5),
Segment::Scan {
channel: Channel::Green,
ms: 345.6,
},
Segment::Gap(1.5),
Segment::Scan {
channel: Channel::Blue,
ms: 345.6,
},
Segment::Sync(9.0),
Segment::Gap(1.5),
Segment::Scan {
channel: Channel::Red,
ms: 345.6,
},
];
/// Scottie's sync arrives after the green and blue scans: 1.5 + 138.24 + 1.5 +
/// 138.24 for S1, and the equivalent for the others.
const fn scottie_sync_offset(scan_ms: f64) -> f64 {
1.5 + scan_ms + 1.5 + scan_ms
}
// ---------------------------------------------------------------------------
// Robot — luminance plus chrominance, the chroma scans at half the width.
// ---------------------------------------------------------------------------
const ROBOT_36: &[Segment] = &[
Segment::Sync(9.0),
Segment::Gap(3.0),
Segment::Scan {
channel: Channel::LumaOdd,
ms: 88.0,
},
Segment::Gap(4.5),
Segment::Gap(1.5),
Segment::Scan {
channel: Channel::ChromaAlternating,
ms: 44.0,
},
];
const ROBOT_72: &[Segment] = &[
Segment::Sync(9.0),
Segment::Gap(3.0),
Segment::Scan {
channel: Channel::LumaOdd,
ms: 138.0,
},
Segment::Gap(4.5),
Segment::Gap(1.5),
Segment::Scan {
channel: Channel::ChromaR,
ms: 69.0,
},
Segment::Gap(4.5),
Segment::Gap(1.5),
Segment::Scan {
channel: Channel::ChromaB,
ms: 69.0,
},
];
// ---------------------------------------------------------------------------
// PD — one transmitted line carries two image lines: the luminance of both,
// with a single pair of chroma scans shared between them.
// ---------------------------------------------------------------------------
macro_rules! pd_segments {
($name:ident, $scan:expr) => {
const $name: &[Segment] = &[
Segment::Sync(20.0),
Segment::Gap(2.08),
Segment::Scan {
channel: Channel::LumaOdd,
ms: $scan,
},
Segment::Scan {
channel: Channel::ChromaR,
ms: $scan,
},
Segment::Scan {
channel: Channel::ChromaB,
ms: $scan,
},
Segment::Scan {
channel: Channel::LumaEven,
ms: $scan,
},
];
};
}
pd_segments!(PD_50, 91.520);
pd_segments!(PD_90, 170.240);
pd_segments!(PD_120, 121.600);
pd_segments!(PD_160, 195.584);
pd_segments!(PD_180, 183.040);
pd_segments!(PD_240, 244.672);
pd_segments!(PD_290, 228.800);
// ---------------------------------------------------------------------------
// Wraase SC2-180 — red, green, blue in that order after one porch.
// ---------------------------------------------------------------------------
const WRAASE_SC2_180: &[Segment] = &[
Segment::Sync(5.5225),
Segment::Gap(0.5),
Segment::Scan {
channel: Channel::Red,
ms: 235.0,
},
Segment::Scan {
channel: Channel::Green,
ms: 235.0,
},
Segment::Scan {
channel: Channel::Blue,
ms: 235.0,
},
];
/// Every mode this decoder knows, in VIS order.
pub static MODES: &[SstvMode] = &[
SstvMode {
vis: 8,
name: "Robot 36",
width: 320,
height: 240,
line_ms: 150.0,
segments: ROBOT_36,
color: ColorModel::YCrCbAlternating,
lines_per_transmission: 1,
sync_offset_ms: 0.0,
},
SstvMode {
vis: 12,
name: "Robot 72",
width: 320,
height: 240,
line_ms: 300.0,
segments: ROBOT_72,
color: ColorModel::YCrCb,
lines_per_transmission: 1,
sync_offset_ms: 0.0,
},
SstvMode {
vis: 40,
name: "Martin M2",
width: 320,
height: 256,
line_ms: 226.798,
segments: MARTIN_M2,
color: ColorModel::Rgb,
lines_per_transmission: 1,
sync_offset_ms: 0.0,
},
SstvMode {
vis: 44,
name: "Martin M1",
width: 320,
height: 256,
line_ms: 446.446,
segments: MARTIN_M1,
color: ColorModel::Rgb,
lines_per_transmission: 1,
sync_offset_ms: 0.0,
},
SstvMode {
vis: 55,
name: "Wraase SC2-180",
width: 320,
height: 256,
line_ms: 711.0225,
segments: WRAASE_SC2_180,
color: ColorModel::Rgb,
lines_per_transmission: 1,
sync_offset_ms: 0.0,
},
SstvMode {
vis: 56,
name: "Scottie S2",
width: 320,
height: 256,
line_ms: 277.692,
segments: SCOTTIE_S2,
color: ColorModel::Rgb,
lines_per_transmission: 1,
sync_offset_ms: scottie_sync_offset(88.064),
},
SstvMode {
vis: 60,
name: "Scottie S1",
width: 320,
height: 256,
line_ms: 428.22,
segments: SCOTTIE_S1,
color: ColorModel::Rgb,
lines_per_transmission: 1,
sync_offset_ms: scottie_sync_offset(138.240),
},
SstvMode {
vis: 76,
name: "Scottie DX",
width: 320,
height: 256,
line_ms: 1050.3,
segments: SCOTTIE_DX,
color: ColorModel::Rgb,
lines_per_transmission: 1,
sync_offset_ms: scottie_sync_offset(345.6),
},
SstvMode {
vis: 93,
name: "PD50",
width: 320,
height: 256,
line_ms: 388.16,
segments: PD_50,
color: ColorModel::YCrCbPaired,
lines_per_transmission: 2,
sync_offset_ms: 0.0,
},
SstvMode {
vis: 94,
name: "PD290",
width: 800,
height: 616,
line_ms: 937.28,
segments: PD_290,
color: ColorModel::YCrCbPaired,
lines_per_transmission: 2,
sync_offset_ms: 0.0,
},
SstvMode {
vis: 95,
name: "PD120",
width: 640,
height: 496,
line_ms: 508.48,
segments: PD_120,
color: ColorModel::YCrCbPaired,
lines_per_transmission: 2,
sync_offset_ms: 0.0,
},
SstvMode {
vis: 96,
name: "PD180",
width: 640,
height: 496,
line_ms: 754.24,
segments: PD_180,
color: ColorModel::YCrCbPaired,
lines_per_transmission: 2,
sync_offset_ms: 0.0,
},
SstvMode {
vis: 97,
name: "PD240",
width: 640,
height: 496,
line_ms: 1000.768,
segments: PD_240,
color: ColorModel::YCrCbPaired,
lines_per_transmission: 2,
sync_offset_ms: 0.0,
},
SstvMode {
vis: 98,
name: "PD160",
width: 512,
height: 400,
line_ms: 804.416,
segments: PD_160,
color: ColorModel::YCrCbPaired,
lines_per_transmission: 2,
sync_offset_ms: 0.0,
},
SstvMode {
vis: 99,
name: "PD90",
width: 320,
height: 256,
line_ms: 703.04,
segments: PD_90,
color: ColorModel::YCrCbPaired,
lines_per_transmission: 2,
sync_offset_ms: 0.0,
},
];
/// Look a mode up by the VIS code that announced it.
pub fn mode_for_vis(vis: u8) -> Option<&'static SstvMode> {
MODES.iter().find(|mode| mode.vis == vis)
}
/// Map an instantaneous frequency to an 8-bit level: 1500 Hz is black, 2300 Hz
/// white. Frequencies outside the band clamp rather than wrap, so a sync pulse
/// that lands inside a scan reads as black instead of as bright noise.
pub fn level_from_hz(hz: f32) -> u8 {
let level = (hz - BLACK_HZ) * (255.0 / (WHITE_HZ - BLACK_HZ));
level.clamp(0.0, 255.0).round() as u8
}
#[cfg(test)]
mod tests {
use super::*;
// The segment list and the published line time are two statements of the
// same fact, entered by hand from the specification. If a digit is wrong in
// one it is unlikely to be wrong identically in the other.
#[test]
fn segments_add_up_to_the_published_line_time() {
for mode in MODES {
let sum = mode.segments_ms();
assert!(
(sum - mode.line_ms).abs() < 0.001,
"{}: segments total {:.4} ms, line time says {:.4} ms",
mode.name,
sum,
mode.line_ms,
);
}
}
#[test]
fn vis_codes_are_unique_and_resolvable() {
for mode in MODES {
assert_eq!(mode_for_vis(mode.vis).map(|m| m.name), Some(mode.name));
}
let mut codes: Vec<u8> = MODES.iter().map(|m| m.vis).collect();
codes.sort_unstable();
let count = codes.len();
codes.dedup();
assert_eq!(codes.len(), count, "two modes claim the same VIS code");
}
#[test]
fn every_mode_scans_enough_channels_for_its_colour_model() {
for mode in MODES {
let scans = mode.scans();
let expected = match mode.color {
ColorModel::Rgb => 3,
ColorModel::YCrCbAlternating => 2,
ColorModel::YCrCb => 3,
ColorModel::YCrCbPaired => 4,
};
assert_eq!(
scans.len(),
expected,
"{} has {} scans",
mode.name,
scans.len()
);
}
}
// Frame durations are what operators know these modes by — the number in
// the name is the number of seconds.
#[test]
fn frame_durations_match_the_names() {
for (vis, secs) in [(8u8, 36.0), (12, 72.0), (93, 50.0), (99, 90.0), (95, 126.0)] {
let mode = mode_for_vis(vis).expect("mode in table");
let actual = mode.frame_secs();
assert!(
(actual - secs).abs() < 1.5,
"{} takes {:.1} s, expected about {:.0} s",
mode.name,
actual,
secs,
);
}
}
#[test]
fn levels_span_black_to_white_and_clamp_outside() {
assert_eq!(level_from_hz(BLACK_HZ), 0);
assert_eq!(level_from_hz(WHITE_HZ), 255);
assert_eq!(level_from_hz(1900.0), 128);
assert_eq!(level_from_hz(SYNC_HZ), 0, "a sync pulse must read as black");
assert_eq!(level_from_hz(3000.0), 255);
}
}
+236
View File
@@ -0,0 +1,236 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
//! VIS header detection.
//!
//! Every transmission announces its mode in a fixed preamble:
//!
//! | Part | Tone | Duration |
//! |------|------|----------|
//! | Leader | 1900 Hz | 300 ms |
//! | Break | 1200 Hz | 10 ms |
//! | Leader | 1900 Hz | 300 ms |
//! | Start bit | 1200 Hz | 30 ms |
//! | 7 data bits, LSB first | 1100 Hz = 1, 1300 Hz = 0 | 30 ms each |
//! | Even parity | as above | 30 ms |
//! | Stop bit | 1200 Hz | 30 ms |
//!
//! The detector looks for the start bit standing behind a leader, reads the
//! eight bits that follow, and checks the parity. Parity is the only integrity
//! check the header has, so a code that fails it is discarded rather than
//! guessed at — decoding 114 seconds of Martin M1 as Scottie DX produces a
//! convincing-looking image of nothing.
/// Tone durations, in milliseconds.
const BIT_MS: f64 = 30.0;
const LEADER_MS: f64 = 300.0;
/// How far a tone may sit from its nominal frequency and still be recognised.
/// Wide enough for a rig tuned a little off, narrow enough that 1100, 1200,
/// 1300 and 1900 Hz stay distinct.
const TONE_TOLERANCE_HZ: f32 = 60.0;
const LEADER_HZ: f32 = 1900.0;
const START_HZ: f32 = 1200.0;
const ONE_HZ: f32 = 1100.0;
const ZERO_HZ: f32 = 1300.0;
/// A VIS header found in the stream.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct VisHit {
/// The code, which names the mode.
pub code: u8,
/// Index just past the stop bit: where the image itself begins.
pub image_start: usize,
}
fn near(freq: f32, target: f32) -> bool {
(freq - target).abs() <= TONE_TOLERANCE_HZ
}
/// Mean frequency over the middle 60% of a bit cell, which keeps the filter's
/// transitions at either edge out of the measurement.
fn bit_frequency(freqs: &[f32], start: f64, samples_per_bit: f64) -> Option<f32> {
let from = (start + samples_per_bit * 0.2).round() as usize;
let to = (start + samples_per_bit * 0.8).round() as usize;
if to <= from || to > freqs.len() {
return None;
}
let window = &freqs[from..to];
Some(window.iter().sum::<f32>() / window.len() as f32)
}
/// Search `freqs` for a VIS header, starting at `from`.
///
/// Returns the first header whose parity checks out. `freqs` is instantaneous
/// frequency in Hz, one entry per audio sample.
pub fn find_vis(freqs: &[f32], sample_rate: u32, from: usize) -> Option<VisHit> {
let sr = f64::from(sample_rate);
// Header tones are 10 ms at the shortest, so a millisecond of averaging
// costs nothing and takes the demodulator's ripple — ±95 Hz at 1200 Hz —
// out of tones that are 100 Hz apart.
let smoothed = crate::demod::smooth(freqs, (sr / 1000.0).round() as usize);
let freqs = smoothed.as_slice();
let samples_per_bit = BIT_MS / 1000.0 * sr;
let leader_samples = (LEADER_MS / 1000.0 * sr) as usize;
// The start bit must be at least this long to be one.
let min_start_run = (samples_per_bit * 0.7) as usize;
// A leader has to precede the start bit. Half of one is enough evidence,
// and asking for less than the full 300 ms means the search still works on
// a buffer that begins part-way through the header.
let leader_needed = leader_samples / 2;
// The bits themselves may run to the very end of what has arrived so far;
// reading them is what decides whether there is enough, not this bound.
let mut i = from.max(leader_needed);
while i < freqs.len() {
if !near(freqs[i], START_HZ) {
i += 1;
continue;
}
// Measure the run of start tone.
let mut run = 0usize;
while i + run < freqs.len() && near(freqs[i + run], START_HZ) {
run += 1;
}
if run < min_start_run {
i += run.max(1);
continue;
}
// What came before it: the leader. Sampled rather than scanned in
// full, since only its identity matters, not its exact length.
let leader_from = i - leader_needed;
let leader_hits = freqs[leader_from..i]
.iter()
.step_by(16)
.filter(|&&f| near(f, LEADER_HZ))
.count();
let leader_total = freqs[leader_from..i].iter().step_by(16).count();
if leader_total == 0 || (leader_hits as f64) < 0.7 * leader_total as f64 {
i += run;
continue;
}
// Bits follow the start bit, which the run just measured. Use the run's
// own end rather than a nominal offset, so a start bit stretched or
// clipped by the filter does not shift every bit after it.
let bits_start = (i + run) as f64;
let mut bits = [false; 8];
let mut readable = true;
for (index, bit) in bits.iter_mut().enumerate() {
let at = bits_start + samples_per_bit * index as f64;
match bit_frequency(freqs, at, samples_per_bit) {
Some(freq) if near(freq, ONE_HZ) => *bit = true,
Some(freq) if near(freq, ZERO_HZ) => *bit = false,
_ => {
readable = false;
break;
}
}
}
if !readable {
i += run;
continue;
}
// Seven data bits, LSB first, then even parity over them.
let code = bits[..7]
.iter()
.enumerate()
.fold(0u8, |acc, (index, &set)| acc | (u8::from(set) << index));
let ones = bits[..7].iter().filter(|&&b| b).count() + usize::from(bits[7]);
if ones % 2 != 0 {
i += run;
continue;
}
// Past the stop bit is the image.
let image_start = (bits_start + samples_per_bit * 9.0).round() as usize;
return Some(VisHit { code, image_start });
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use crate::encode::{vis_tones, Tone};
fn freqs_from_tones(tones: &[Tone], sample_rate: u32) -> Vec<f32> {
let mut out = Vec::new();
for tone in tones {
let samples = (tone.ms / 1000.0 * f64::from(sample_rate)).round() as usize;
out.extend(std::iter::repeat_n(tone.hz, samples));
}
out
}
#[test]
fn reads_every_code_the_mode_table_knows() {
for mode in crate::mode::MODES {
let freqs = freqs_from_tones(&vis_tones(mode.vis), 48_000);
let hit = find_vis(&freqs, 48_000, 0)
.unwrap_or_else(|| panic!("{} header not found", mode.name));
assert_eq!(
hit.code, mode.vis,
"{} decoded as VIS {}",
mode.name, hit.code
);
}
}
#[test]
fn the_image_starts_after_the_stop_bit() {
let sample_rate = 48_000;
let freqs = freqs_from_tones(&vis_tones(44), sample_rate);
let hit = find_vis(&freqs, sample_rate, 0).expect("header");
// Header is 300 + 10 + 300 ms of leader and break, then ten 30 ms bits.
let expected = ((300.0 + 10.0 + 300.0 + 300.0) / 1000.0 * f64::from(sample_rate)) as usize;
let slack = sample_rate as usize / 100; // 10 ms
assert!(
hit.image_start.abs_diff(expected) < slack,
"image starts at {}, expected about {expected}",
hit.image_start,
);
}
#[test]
fn a_header_with_broken_parity_is_not_a_header() {
let sample_rate = 48_000;
let mut tones = vis_tones(44);
// Flip the parity bit alone: seven data bits still say Martin M1, but
// nothing now vouches for them.
let parity = tones.len() - 2;
tones[parity].hz = if tones[parity].hz == ONE_HZ {
ZERO_HZ
} else {
ONE_HZ
};
let freqs = freqs_from_tones(&tones, sample_rate);
assert_eq!(find_vis(&freqs, sample_rate, 0), None);
}
#[test]
fn tones_without_a_leader_are_not_a_header() {
let sample_rate = 48_000;
let mut tones = vis_tones(44);
// Same bits, but the leader before them is a pixel-band tone — which is
// what a passing image looks like.
tones[0].hz = 2000.0;
tones[2].hz = 2000.0;
let freqs = freqs_from_tones(&tones, sample_rate);
assert_eq!(find_vis(&freqs, sample_rate, 0), None);
}
#[test]
fn a_header_part_way_into_the_buffer_is_still_found() {
let sample_rate = 48_000;
let mut freqs = vec![1750.0f32; sample_rate as usize]; // a second of picture
freqs.extend(freqs_from_tones(&vis_tones(60), sample_rate));
let hit = find_vis(&freqs, sample_rate, 0).expect("header");
assert_eq!(hit.code, 60);
}
}
+384
View File
@@ -0,0 +1,384 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
//! Decode what was encoded, and compare the pictures.
//!
//! A decoder for a picture format can only really be tested against a picture.
//! These tests build a test card, transmit it in each mode through the
//! encoder, and hold the decoder to what comes back — pixel by pixel, with a
//! tolerance that accounts for the round trip through frequency and, for the
//! colour modes, through a chroma channel at half the width.
//!
//! What this does not test is the timing table itself: the encoder reads the
//! same numbers as the decoder, so a wrong line duration would cancel out.
//! That is checked in `mode.rs` against the published line times instead.
use trx_sstv::encode::{encode, Frame};
use trx_sstv::mode::mode_for_vis;
use trx_sstv::{SstvConfig, SstvDecoder, SstvEvent, SstvImage};
const SAMPLE_RATE: u32 = 48_000;
/// A test card with something for every part of the decoder to get wrong:
/// vertical colour bars catch channels swapped or shifted, the horizontal
/// gradient catches a line-timing drift, and the corner blocks catch a picture
/// that arrives upside down or mirrored.
fn test_card(width: usize, height: usize) -> Vec<u8> {
let mut rgb = vec![0u8; width * height * 3];
let bars: [(u8, u8, u8); 8] = [
(255, 255, 255),
(255, 255, 0),
(0, 255, 255),
(0, 255, 0),
(255, 0, 255),
(255, 0, 0),
(0, 0, 255),
(0, 0, 0),
];
for y in 0..height {
for x in 0..width {
let at = (y * width + x) * 3;
let (r, g, b) = if y < height / 2 {
bars[x * bars.len() / width]
} else {
let ramp = (x * 255 / width.max(1)) as u8;
let down = (y * 255 / height.max(1)) as u8;
(ramp, down, 255 - ramp)
};
rgb[at] = r;
rgb[at + 1] = g;
rgb[at + 2] = b;
}
}
// Corner marks: red top-left, blue bottom-right.
for y in 0..height.min(8) {
for x in 0..width.min(8) {
let at = (y * width + x) * 3;
rgb[at] = 255;
rgb[at + 1] = 0;
rgb[at + 2] = 0;
}
}
for y in height.saturating_sub(8)..height {
for x in width.saturating_sub(8)..width {
let at = (y * width + x) * 3;
rgb[at] = 0;
rgb[at + 1] = 0;
rgb[at + 2] = 255;
}
}
rgb
}
/// Run audio through the decoder in blocks the size a sound card delivers.
///
/// A little silence is fed after the signal, as a receiver that keeps
/// listening supplies: the demodulator is a filter, so the last millisecond of
/// any transmission needs the samples after it before it can be read.
fn decode(audio: &[f32]) -> (Vec<SstvImage>, usize) {
let mut tail = audio.to_vec();
tail.extend(std::iter::repeat_n(0.0, SAMPLE_RATE as usize / 20));
let audio = tail.as_slice();
let mut decoder = SstvDecoder::new(SAMPLE_RATE, SstvConfig::default());
let mut images = Vec::new();
let mut rows = 0;
for block in audio.chunks(1024) {
for event in decoder.process_samples(block) {
match event {
SstvEvent::Row { .. } => rows += 1,
SstvEvent::Complete(image) => images.push(image),
SstvEvent::Started { .. } => {}
}
}
}
(images, rows)
}
/// Mean absolute error per colour channel between two same-sized images.
fn mean_error(a: &[u8], b: &[u8]) -> f64 {
assert_eq!(a.len(), b.len());
let total: u64 = a
.iter()
.zip(b)
.map(|(x, y)| u64::from(x.abs_diff(*y)))
.sum();
total as f64 / a.len() as f64
}
/// Error over the part of the picture away from channel edges, where a decoder
/// that is a pixel out on a hard colour boundary would otherwise dominate.
fn interior_error(mode_width: usize, height: usize, sent: &[u8], got: &[u8]) -> f64 {
let mut total = 0u64;
let mut count = 0u64;
for y in 2..height.saturating_sub(2) {
for x in 4..mode_width.saturating_sub(4) {
// Skip the columns where the bars change, which is where a
// half-pixel timing difference shows up as a whole-colour error.
if x % (mode_width / 8) < 3 {
continue;
}
let at = (y * mode_width + x) * 3;
for channel in 0..3 {
total += u64::from(sent[at + channel].abs_diff(got[at + channel]));
count += 1;
}
}
}
total as f64 / count.max(1) as f64
}
fn round_trip(vis: u8, tolerance: f64) {
let mode = mode_for_vis(vis).expect("mode in table");
let width = usize::from(mode.width);
let height = usize::from(mode.height);
let sent = test_card(width, height);
let frame = Frame {
width,
height,
rgb: &sent,
};
let audio = encode(mode, &frame, SAMPLE_RATE);
let (images, rows) = decode(&audio);
assert_eq!(
images.len(),
1,
"{}: expected one picture, got {}",
mode.name,
images.len()
);
let image = &images[0];
assert!(
image.complete,
"{}: reception did not reach the bottom",
mode.name
);
assert_eq!(image.mode, mode.name);
assert_eq!(
image.lines, mode.height,
"{}: {} of {} lines",
mode.name, image.lines, mode.height
);
assert_eq!(
rows, height,
"{}: emitted {rows} rows for {height} lines",
mode.name
);
let error = interior_error(width, height, &sent, &image.rgb);
assert!(
error < tolerance,
"{}: mean error {error:.1} levels, tolerance {tolerance:.1}",
mode.name,
);
}
#[test]
fn martin_m1_round_trips() {
round_trip(44, 6.0);
}
#[test]
fn martin_m2_round_trips() {
round_trip(40, 8.0);
}
#[test]
fn scottie_s1_round_trips() {
round_trip(60, 6.0);
}
#[test]
fn scottie_s2_round_trips() {
round_trip(56, 8.0);
}
#[test]
fn wraase_sc2_180_round_trips() {
round_trip(55, 6.0);
}
// The colour-difference modes lose chroma resolution by design, so the bars
// bleed into one another at their edges; the tolerance is on the interior.
#[test]
fn robot_72_round_trips() {
round_trip(12, 14.0);
}
#[test]
fn robot_36_round_trips() {
// One chroma channel per line, the other carried over from the line
// before, so alternate lines are a line stale in one channel.
round_trip(8, 26.0);
}
#[test]
fn pd90_round_trips() {
round_trip(99, 14.0);
}
#[test]
fn pd120_round_trips() {
round_trip(95, 14.0);
}
/// Silence before and after is the normal case — a receiver is not started at
/// the instant the transmission does.
#[test]
fn survives_silence_around_the_transmission() {
let mode = mode_for_vis(44).expect("Martin M1");
let (width, height) = (usize::from(mode.width), usize::from(mode.height));
let sent = test_card(width, height);
let frame = Frame {
width,
height,
rgb: &sent,
};
let mut audio = vec![0.0f32; SAMPLE_RATE as usize * 2];
audio.extend(encode(mode, &frame, SAMPLE_RATE));
audio.extend(std::iter::repeat_n(0.0, SAMPLE_RATE as usize));
let (images, _) = decode(&audio);
assert_eq!(
images.len(),
1,
"expected one picture from a transmission in silence"
);
assert!(images[0].complete);
}
/// A transmission cut off part-way is what a fade or a shut-down transmitter
/// produces. The lines that did arrive are worth keeping.
#[test]
fn a_truncated_transmission_still_yields_its_lines() {
let mode = mode_for_vis(44).expect("Martin M1");
let (width, height) = (usize::from(mode.width), usize::from(mode.height));
let sent = test_card(width, height);
let frame = Frame {
width,
height,
rgb: &sent,
};
let full = encode(mode, &frame, SAMPLE_RATE);
// Two thirds of the picture, then silence for long enough that the decoder
// stops waiting for the rest.
let mut audio = full[..full.len() * 2 / 3].to_vec();
audio.extend(std::iter::repeat_n(0.0, SAMPLE_RATE as usize * 5));
let (images, _) = decode(&audio);
assert_eq!(
images.len(),
1,
"a cut-off transmission produced no picture"
);
let image = &images[0];
assert!(
!image.complete,
"a two-thirds transmission reported as complete"
);
assert!(
image.lines > mode.height / 2 && image.lines < mode.height,
"{} lines of {} arrived",
image.lines,
mode.height,
);
// What did arrive is the top of the picture, and it is right.
let rows = usize::from(image.lines).saturating_sub(4);
let error = mean_error(&sent[..width * rows * 3], &image.rgb[..width * rows * 3]);
assert!(
error < 12.0,
"the lines that arrived are wrong: mean error {error:.1}"
);
}
/// Two pictures back to back: the decoder has to finish the first and pick up
/// the header of the second.
#[test]
fn decodes_a_second_transmission_after_the_first() {
let mode = mode_for_vis(40).expect("Martin M2");
let (width, height) = (usize::from(mode.width), usize::from(mode.height));
let sent = test_card(width, height);
let frame = Frame {
width,
height,
rgb: &sent,
};
let one = encode(mode, &frame, SAMPLE_RATE);
let mut audio = one.clone();
audio.extend(std::iter::repeat_n(0.0, SAMPLE_RATE as usize / 2));
audio.extend(one);
let (images, _) = decode(&audio);
assert_eq!(
images.len(),
2,
"expected two pictures, got {}",
images.len()
);
assert!(
images.iter().all(|image| image.complete),
"a picture did not finish"
);
}
/// Noise on the signal is the normal condition on HF. The picture should
/// degrade, not fall apart.
#[test]
fn decodes_through_noise() {
let mode = mode_for_vis(44).expect("Martin M1");
let (width, height) = (usize::from(mode.width), usize::from(mode.height));
let sent = test_card(width, height);
let frame = Frame {
width,
height,
rgb: &sent,
};
let clean = encode(mode, &frame, SAMPLE_RATE);
// Deterministic pseudo-noise at about 20 dB below the signal.
let mut seed = 0x5eed_1234u32;
let noisy: Vec<f32> = clean
.iter()
.map(|sample| {
seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
let noise = (seed >> 8) as f32 / f32::from(u16::MAX) / 256.0 - 0.5;
sample + noise * 0.2
})
.collect();
let (images, _) = decode(&noisy);
assert_eq!(images.len(), 1, "noise cost the whole picture");
let image = &images[0];
assert!(image.complete, "noise cost the bottom of the picture");
let error = interior_error(width, height, &sent, &image.rgb);
assert!(error < 20.0, "mean error through noise {error:.1} levels");
}
/// The sound card that plays the signal and the one that records it never
/// agree exactly. A part-per-thousand error is far worse than reality and the
/// picture should still stand up.
#[test]
fn tolerates_a_transmitter_clock_that_runs_fast() {
let mode = mode_for_vis(44).expect("Martin M1");
let (width, height) = (usize::from(mode.width), usize::from(mode.height));
let sent = test_card(width, height);
let frame = Frame {
width,
height,
rgb: &sent,
};
// Encoding at a slightly different rate and decoding at 48 kHz is exactly
// a clock error: every duration is stretched by the same factor.
let audio = encode(mode, &frame, 48_048);
let (images, _) = decode(&audio);
assert_eq!(images.len(), 1, "a 0.1% clock error cost the picture");
let error = interior_error(width, height, &sent, &images[0].rgb);
assert!(
error < 12.0,
"mean error with a fast clock {error:.1} levels"
);
}