Compare commits

..
1 Commits
Author SHA1 Message Date
sjg 0680c3ebed [feat](trx-sstv): decode SSTV pictures
CI / lint (pull_request) Failing after 1s
CI / test (pull_request) Successful in 7m34s
CI / frontend (pull_request) Successful in 3m13s
CI / reuse (pull_request) Successful in 3s
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>
2026-08-05 23:19:55 +02:00
8 changed files with 123 additions and 465 deletions
@@ -20,26 +20,14 @@ fn main() {
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]
[(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)
}
if ((x + y) / 16) % 2 == 0 { (240, 240, 40) } else { (20, 20, 90) }
};
rgb[at] = r;
rgb[at + 1] = g;
@@ -51,17 +39,9 @@ fn main() {
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");
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 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());
@@ -78,14 +58,7 @@ fn main() {
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
);
std::fs::write(format!("{dir}/{name}-decoded.png"), canvas.to_png().expect("png")).expect("write");
println!("{}: {} lines, complete={}", mode.name, image.lines, image.complete);
}
}
+5 -15
View File
@@ -192,8 +192,7 @@ impl SstvDecoder {
// 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);
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;
}
@@ -385,8 +384,7 @@ impl SstvDecoder {
if from >= self.freqs.len() {
return None;
}
self.find_sync(from, to, mode)
.map(|at| self.base + at as u64)
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
@@ -463,10 +461,7 @@ fn sample_scan(
(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()),
)
(at.min(freqs.len().saturating_sub(1)), (at + 1).min(freqs.len()))
};
if to <= from {
out.push(0);
@@ -508,19 +503,14 @@ fn compose_rows(reception: &mut Reception, scans: &[(Channel, Vec<u8>)]) -> Vec<
// 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();
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 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)]
}
+4 -19
View File
@@ -176,13 +176,7 @@ fn design_bandpass_fir(sample_rate: u32) -> Vec<f32> {
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 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;
@@ -229,10 +223,7 @@ mod tests {
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,
);
demod.process_into(&tone(freq, sample_rate, sample_rate as usize / 10), &mut out);
let settled = &out[HILBERT_TAPS * 2..];
settled.iter().sum::<f32>() / settled.len() as f32
}
@@ -285,13 +276,7 @@ mod tests {
// itself takes the filter's length to pass through.
let before = out[4800 - 200..4800 - 100].iter().sum::<f32>() / 100.0;
let after = out[4800 + 200..4800 + 300].iter().sum::<f32>() / 100.0;
assert!(
(before - 1500.0).abs() < 10.0,
"before the step: {before:.1} Hz"
);
assert!(
(after - 2300.0).abs() < 10.0,
"after the step: {after:.1} Hz"
);
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");
}
}
+10 -39
View File
@@ -29,22 +29,10 @@ pub fn hz_from_level(level: u8) -> f32 {
/// The tones of a VIS header announcing `vis`.
pub fn vis_tones(vis: u8) -> Vec<Tone> {
let mut tones = vec![
Tone {
hz: 1900.0,
ms: 300.0,
},
Tone {
hz: 1200.0,
ms: 10.0,
},
Tone {
hz: 1900.0,
ms: 300.0,
},
Tone {
hz: 1200.0,
ms: 30.0,
}, // start bit
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 {
@@ -52,20 +40,11 @@ pub fn vis_tones(vis: u8) -> Vec<Tone> {
if set {
ones += 1;
}
tones.push(Tone {
hz: if set { 1100.0 } else { 1300.0 },
ms: 30.0,
});
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.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
}
@@ -118,10 +97,7 @@ pub fn encode_tones(mode: &SstvMode, frame: &Frame<'_>) -> Vec<Tone> {
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.push(Tone { hz: hz_from_level(level), ms: pixel_ms });
}
}
}
@@ -173,8 +149,7 @@ fn chroma(mode: &SstvMode, frame: &Frame<'_>, x: usize, top: usize, want_cr: boo
/// demodulator sees no step at a tone boundary that isn't in the signal.
pub fn render(tones: &[Tone], sample_rate: u32) -> Vec<f32> {
let sr = f64::from(sample_rate);
let mut out =
Vec::with_capacity((tones.iter().map(|t| t.ms).sum::<f64>() / 1000.0 * sr) as usize);
let mut out = Vec::with_capacity((tones.iter().map(|t| t.ms).sum::<f64>() / 1000.0 * sr) as usize);
let mut phase = 0.0f64;
// Each tone's *end* is rounded to a sample, rather than its length: a
// pixel of 25.5 samples rounded up on its own puts a whole line 600
@@ -220,11 +195,7 @@ mod tests {
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 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;
+1 -2
View File
@@ -107,8 +107,7 @@ impl ImageCanvas {
.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()))?;
std::fs::write(&path, self.to_png()?).map_err(|e| format!("write {}: {e}", path.display()))?;
Ok(path)
}
}
+75 -257
View File
@@ -147,40 +147,22 @@ impl SstvMode {
const MARTIN_M1: &[Segment] = &[
Segment::Sync(4.862),
Segment::Gap(0.572),
Segment::Scan {
channel: Channel::Green,
ms: 146.432,
},
Segment::Scan { channel: Channel::Green, ms: 146.432 },
Segment::Gap(0.572),
Segment::Scan {
channel: Channel::Blue,
ms: 146.432,
},
Segment::Scan { channel: Channel::Blue, ms: 146.432 },
Segment::Gap(0.572),
Segment::Scan {
channel: Channel::Red,
ms: 146.432,
},
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::Scan { channel: Channel::Green, ms: 73.216 },
Segment::Gap(0.572),
Segment::Scan {
channel: Channel::Blue,
ms: 73.216,
},
Segment::Scan { channel: Channel::Blue, ms: 73.216 },
Segment::Gap(0.572),
Segment::Scan {
channel: Channel::Red,
ms: 73.216,
},
Segment::Scan { channel: Channel::Red, ms: 73.216 },
Segment::Gap(0.572),
];
@@ -192,59 +174,32 @@ const MARTIN_M2: &[Segment] = &[
const SCOTTIE_S1: &[Segment] = &[
Segment::Gap(1.5),
Segment::Scan {
channel: Channel::Green,
ms: 138.240,
},
Segment::Scan { channel: Channel::Green, ms: 138.240 },
Segment::Gap(1.5),
Segment::Scan {
channel: Channel::Blue,
ms: 138.240,
},
Segment::Scan { channel: Channel::Blue, ms: 138.240 },
Segment::Sync(9.0),
Segment::Gap(1.5),
Segment::Scan {
channel: Channel::Red,
ms: 138.240,
},
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::Scan { channel: Channel::Green, ms: 88.064 },
Segment::Gap(1.5),
Segment::Scan {
channel: Channel::Blue,
ms: 88.064,
},
Segment::Scan { channel: Channel::Blue, ms: 88.064 },
Segment::Sync(9.0),
Segment::Gap(1.5),
Segment::Scan {
channel: Channel::Red,
ms: 88.064,
},
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::Scan { channel: Channel::Green, ms: 345.6 },
Segment::Gap(1.5),
Segment::Scan {
channel: Channel::Blue,
ms: 345.6,
},
Segment::Scan { channel: Channel::Blue, ms: 345.6 },
Segment::Sync(9.0),
Segment::Gap(1.5),
Segment::Scan {
channel: Channel::Red,
ms: 345.6,
},
Segment::Scan { channel: Channel::Red, ms: 345.6 },
];
/// Scottie's sync arrives after the green and blue scans: 1.5 + 138.24 + 1.5 +
@@ -260,37 +215,22 @@ const fn scottie_sync_offset(scan_ms: f64) -> f64 {
const ROBOT_36: &[Segment] = &[
Segment::Sync(9.0),
Segment::Gap(3.0),
Segment::Scan {
channel: Channel::LumaOdd,
ms: 88.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,
},
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::Scan { channel: Channel::LumaOdd, ms: 138.0 },
Segment::Gap(4.5),
Segment::Gap(1.5),
Segment::Scan {
channel: Channel::ChromaR,
ms: 69.0,
},
Segment::Scan { channel: Channel::ChromaR, ms: 69.0 },
Segment::Gap(4.5),
Segment::Gap(1.5),
Segment::Scan {
channel: Channel::ChromaB,
ms: 69.0,
},
Segment::Scan { channel: Channel::ChromaB, ms: 69.0 },
];
// ---------------------------------------------------------------------------
@@ -303,22 +243,10 @@ macro_rules! pd_segments {
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,
},
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 },
];
};
}
@@ -338,186 +266,87 @@ pd_segments!(PD_290, 228.800);
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,
},
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,
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,
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,
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,
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,
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),
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),
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),
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,
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,
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,
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,
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,
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,
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,
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,
},
];
@@ -548,9 +377,7 @@ mod tests {
assert!(
(sum - mode.line_ms).abs() < 0.001,
"{}: segments total {:.4} ms, line time says {:.4} ms",
mode.name,
sum,
mode.line_ms,
mode.name, sum, mode.line_ms,
);
}
}
@@ -577,13 +404,7 @@ mod tests {
ColorModel::YCrCb => 3,
ColorModel::YCrCbPaired => 4,
};
assert_eq!(
scans.len(),
expected,
"{} has {} scans",
mode.name,
scans.len()
);
assert_eq!(scans.len(), expected, "{} has {} scans", mode.name, scans.len());
}
}
@@ -596,10 +417,7 @@ mod tests {
let actual = mode.frame_secs();
assert!(
(actual - secs).abs() < 1.5,
"{} takes {:.1} s, expected about {:.0} s",
mode.name,
actual,
secs,
"{} takes {:.1} s, expected about {:.0} s", mode.name, actual, secs,
);
}
}
+3 -12
View File
@@ -174,11 +174,7 @@ mod tests {
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
);
assert_eq!(hit.code, mode.vis, "{} decoded as VIS {}", mode.name, hit.code);
}
}
@@ -192,8 +188,7 @@ mod tests {
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,
"image starts at {}, expected about {expected}", hit.image_start,
);
}
@@ -204,11 +199,7 @@ mod tests {
// 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
};
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);
}
+18 -87
View File
@@ -134,38 +134,16 @@ fn round_trip(vis: u8, tolerance: f64) {
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 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()
);
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!(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
);
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!(
@@ -231,21 +209,13 @@ 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 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_eq!(images.len(), 1, "expected one picture from a transmission in silence");
assert!(images[0].complete);
}
@@ -256,11 +226,7 @@ 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 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.
@@ -268,29 +234,17 @@ fn a_truncated_transmission_still_yields_its_lines() {
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"
);
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.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,
"{} 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}"
);
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
@@ -300,11 +254,7 @@ 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 frame = Frame { width, height, rgb: &sent };
let one = encode(mode, &frame, SAMPLE_RATE);
let mut audio = one.clone();
@@ -312,16 +262,8 @@ fn decodes_a_second_transmission_after_the_first() {
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"
);
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
@@ -331,11 +273,7 @@ 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 frame = Frame { width, height, rgb: &sent };
let clean = encode(mode, &frame, SAMPLE_RATE);
// Deterministic pseudo-noise at about 20 dB below the signal.
@@ -365,11 +303,7 @@ 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,
};
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);
@@ -377,8 +311,5 @@ fn tolerates_a_transmitter_clock_that_runs_fast() {
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"
);
assert!(error < 12.0, "mean error with a fast clock {error:.1} levels");
}