trx-sstv: an SSTV decoder #43

Merged
sjg merged 1 commits from feat/sstv-decoder into main 2026-08-05 23:53:16 +02:00
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"
);
}