Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0680c3ebed |
Generated
+10
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,64 @@
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
@@ -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>,
|
||||
}
|
||||
@@ -0,0 +1,579 @@
|
||||
// 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,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
// 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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
// 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",
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
// 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());
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
@@ -0,0 +1,433 @@
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
// 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");
|
||||
}
|
||||
Reference in New Issue
Block a user