CI / test (pull_request) Successful in 7m40s
CI / lint (pull_request) Failing after 14m36s
CI / frontend (pull_request) Successful in 3m12s
CI / reuse (pull_request) Successful in 3s
CI / test (push) Successful in 7m32s
CI / frontend (push) Failing after 1m21s
CI / reuse (push) Successful in 2s
CI / lint (push) Successful in 2m16s
A new decoder crate covering the modes SSTV is actually sent in: Martin M1/M2, Scottie S1/S2/DX, Robot 36/72, PD50 through PD290, and Wraase SC2-180. The mode comes from the VIS header every transmission opens with, so nothing has to be told what is arriving. Modes are a table rather than code: a list of segments -- sync, gaps, and one scan per colour channel -- plus a colour model and a geometry. The decoder reads the offset of each scan straight off that list, which is what makes fifteen modes cost about as much as one, and a new mode a table entry. The segment lists are checked against the published line durations in a test, because both are transcribed by hand from the same specification and a digit wrong in one is unlikely to be wrong identically in the other. Signal path: band-pass over the SSTV band, Hilbert FIR, instantaneous frequency by phase difference, then a state machine that walks the transmission a line at a time. Each line is looked for where the mode says it should be and nudged into place by the sync pulse found near it -- two sound cards never agree exactly, and over the two minutes of a Martin M1 frame an uncorrected error of a few parts per million shears the picture visibly. Rows are emitted as they decode, so a picture can be watched arriving, which is most of the appeal of the mode. Four things this cost, each now the reason a piece of it is shaped the way it is: The per-sample frequency estimate ripples by ±95 Hz at 1200 Hz, where the Hilbert approximation is weakest, though its mean is exact. Pixels average over their own window and were always right; the VIS bits and the sync detector classify individual samples and were reading the ripple. Both now read short means. Pixels deliberately still do not, so edges stay where they are. Broadband noise cost the whole picture, not part of it: a phase-difference detector answers whatever is loudest, and there was no input filter. Hence the band-pass, which is what every real decoder does first. A sync search window shorter than a sync pulse rejected every pulse arriving late in it, for being short. The first line's sync search locked onto the VIS stop bit -- 30 ms at exactly the sync frequency, immediately before the picture starts. The header already says where the picture begins, so the first line no longer searches. Tests: nine modes are encoded from a test card and decoded back, compared pixel by pixel, alongside silence around the signal, a transmission cut off part way, two transmissions back to back, 20 dB of noise, and a transmitter clock 0.1% fast. The encoder that produces those signals reads the same table as the decoder, so a round trip tests the decoder and not the timings; the timings are held to the published line durations separately. Nothing is wired into the server or the web UI yet: this is the decoder alone. Signed-off-by: Stan Grams <sjg@haxx.space>
92 lines
3.1 KiB
Rust
92 lines
3.1 KiB
Rust
// 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
|
|
);
|
|
}
|
|
}
|