diff --git a/Cargo.lock b/Cargo.lock index 7a157b87..705418e7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index d39ad9d1..22e62b6c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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", diff --git a/src/decoders/trx-sstv/Cargo.toml b/src/decoders/trx-sstv/Cargo.toml new file mode 100644 index 00000000..d60bd836 --- /dev/null +++ b/src/decoders/trx-sstv/Cargo.toml @@ -0,0 +1,14 @@ +# SPDX-FileCopyrightText: 2026 Stan Grams +# +# 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" diff --git a/src/decoders/trx-sstv/examples/round_trip_png.rs b/src/decoders/trx-sstv/examples/round_trip_png.rs new file mode 100644 index 00000000..f2763519 --- /dev/null +++ b/src/decoders/trx-sstv/examples/round_trip_png.rs @@ -0,0 +1,64 @@ +// SPDX-FileCopyrightText: 2026 Stan Grams +// +// 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); + } +} diff --git a/src/decoders/trx-sstv/src/config.rs b/src/decoders/trx-sstv/src/config.rs new file mode 100644 index 00000000..a63baf90 --- /dev/null +++ b/src/decoders/trx-sstv/src/config.rs @@ -0,0 +1,17 @@ +// SPDX-FileCopyrightText: 2026 Stan Grams +// +// 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, + /// Directory for saved PNGs. `None` keeps images in memory only. + pub output_dir: Option, +} diff --git a/src/decoders/trx-sstv/src/decoder.rs b/src/decoders/trx-sstv/src/decoder.rs new file mode 100644 index 00000000..e363f553 --- /dev/null +++ b/src/decoders/trx-sstv/src/decoder.rs @@ -0,0 +1,579 @@ +// SPDX-FileCopyrightText: 2026 Stan Grams +// +// 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 }, + /// 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, + /// When reception started, in milliseconds since the epoch. + pub started_ms: i64, +} + +enum State { + /// Listening for a header. + Searching, + Receiving(Box), +} + +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>, + last_chroma_b: Option>, +} + +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, + /// 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 { + 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 { + 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) -> 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) { + self.begin_at(mode, image_start as f64, events); + } + + fn begin_at(&mut self, mode: &'static SstvMode, line_start: f64, events: &mut Vec) { + 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) -> 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)> = 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 { + 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 { + 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 { + 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::() / 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)]) -> Vec> { + 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 { + 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, + } +} diff --git a/src/decoders/trx-sstv/src/demod.rs b/src/decoders/trx-sstv/src/demod.rs new file mode 100644 index 00000000..98c496db --- /dev/null +++ b/src/decoders/trx-sstv/src/demod.rs @@ -0,0 +1,282 @@ +// SPDX-FileCopyrightText: 2026 Stan Grams +// +// 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, + bandpass_tail: Vec, + coeffs: [f32; HILBERT_TAPS], + /// The last `HILBERT_TAPS - 1` input samples, priming the next block. + tail: Vec, + 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 { + 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) { + 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 { + 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 { + 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 { + (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::() / 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::() / 100.0; + let after = out[4800 + 200..4800 + 300].iter().sum::() / 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"); + } +} diff --git a/src/decoders/trx-sstv/src/encode.rs b/src/decoders/trx-sstv/src/encode.rs new file mode 100644 index 00000000..fbdad386 --- /dev/null +++ b/src/decoders/trx-sstv/src/encode.rs @@ -0,0 +1,209 @@ +// SPDX-FileCopyrightText: 2026 Stan Grams +// +// 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 { + 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 { + 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 { + let sr = f64::from(sample_rate); + let mut out = Vec::with_capacity((tones.iter().map(|t| t.ms).sum::() / 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 { + 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", + ); + } +} diff --git a/src/decoders/trx-sstv/src/image.rs b/src/decoders/trx-sstv/src/image.rs new file mode 100644 index 00000000..f1366ed0 --- /dev/null +++ b/src/decoders/trx-sstv/src/image.rs @@ -0,0 +1,149 @@ +// SPDX-FileCopyrightText: 2026 Stan Grams +// +// 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, + /// 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, 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 { + 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 { + 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()); + } +} diff --git a/src/decoders/trx-sstv/src/lib.rs b/src/decoders/trx-sstv/src/lib.rs new file mode 100644 index 00000000..0b470e48 --- /dev/null +++ b/src/decoders/trx-sstv/src/lib.rs @@ -0,0 +1,36 @@ +// SPDX-FileCopyrightText: 2026 Stan Grams +// +// 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 = 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}; diff --git a/src/decoders/trx-sstv/src/mode.rs b/src/decoders/trx-sstv/src/mode.rs new file mode 100644 index 00000000..f13f7160 --- /dev/null +++ b/src/decoders/trx-sstv/src/mode.rs @@ -0,0 +1,433 @@ +// SPDX-FileCopyrightText: 2026 Stan Grams +// +// 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 = 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); + } +} diff --git a/src/decoders/trx-sstv/src/vis.rs b/src/decoders/trx-sstv/src/vis.rs new file mode 100644 index 00000000..05abb8f7 --- /dev/null +++ b/src/decoders/trx-sstv/src/vis.rs @@ -0,0 +1,227 @@ +// SPDX-FileCopyrightText: 2026 Stan Grams +// +// 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 { + 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::() / 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 { + 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 { + 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); + } +} diff --git a/src/decoders/trx-sstv/tests/round_trip.rs b/src/decoders/trx-sstv/tests/round_trip.rs new file mode 100644 index 00000000..56c9b148 --- /dev/null +++ b/src/decoders/trx-sstv/tests/round_trip.rs @@ -0,0 +1,315 @@ +// SPDX-FileCopyrightText: 2026 Stan Grams +// +// 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 { + 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, 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 = 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"); +}