// 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"); }