#![cfg(feature = "tnc")]
use std::path::{Path, PathBuf};
use yodel::tnc::{DefaultTncReceiver, TncConfig};
use yodel::{ModemProfile, SampleRate};
struct Track {
file: &'static str,
min_frames: usize,
exact: bool,
}
const TRACKS: &[Track] = &[
Track {
file: "01_40-Mins-Traffic_-on-144.39.wav",
min_frames: 999,
exact: false,
},
Track {
file: "02_100-Mic-E-Bursts-DE-emphasized.wav",
min_frames: 985,
exact: false,
},
Track {
file: "03_100-Mic-E-Bursts-Flat.wav",
min_frames: 100,
exact: true,
},
Track {
file: "04_25-MIns-Drive-Test.wav",
min_frames: 98,
exact: false,
},
];
const MIN_TRACKS_MEASURED: usize = 4;
const _: () = assert!(
TRACKS.len() >= MIN_TRACKS_MEASURED,
"TRACKS must still hold every row MIN_TRACKS_MEASURED claims to count"
);
#[derive(Debug, PartialEq, Eq)]
enum Availability<'a> {
AllPresent,
NonePresent,
Partial {
present: Vec<&'a str>,
missing: Vec<&'a str>,
},
}
fn availability<'a>(inputs: &[(&'a str, bool)]) -> Availability<'a> {
let present: Vec<&'a str> = inputs
.iter()
.filter(|&&(_, exists)| exists)
.map(|&(name, _)| name)
.collect();
let missing: Vec<&'a str> = inputs
.iter()
.filter(|&&(_, exists)| !exists)
.map(|&(name, _)| name)
.collect();
match (present.is_empty(), missing.is_empty()) {
(true, false) => Availability::NonePresent,
(false, false) => Availability::Partial { present, missing },
(_, true) => Availability::AllPresent,
}
}
#[must_use]
fn inputs_ready(what: &str, hint: &str, inputs: &[(&str, bool)]) -> bool {
match availability(inputs) {
Availability::AllPresent => true,
Availability::NonePresent => {
println!("{what}: no input present; skipping ({hint})");
false
}
Availability::Partial { present, missing } => panic!(
"{what}: partially populated input set — {} of {} present. \
Present: {}. Absent: {}. Refusing to measure a subset: it \
would pass while the ratchet covered only the files that \
happened to be here. Provide every input or none ({hint}).",
present.len(),
present.len() + missing.len(),
present.join(", "),
missing.join(", "),
),
}
}
fn presence<'a>(dir: &Path, names: impl IntoIterator<Item = &'a str>) -> Vec<(&'a str, bool)> {
names
.into_iter()
.map(|name| (name, dir.join(name).is_file()))
.collect()
}
fn presence_of<'a>(label: &'a str, path: &Path) -> [(&'a str, bool); 1] {
[(label, path.is_file())]
}
fn fixture(relative: &str) -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(relative)
}
fn decode_count(path: &Path) -> Result<usize, String> {
decode_count_with(path, ModemProfile::BELL_202)
}
fn decode_count_with(path: &Path, profile: ModemProfile) -> Result<usize, String> {
let mut reader =
hound::WavReader::open(path).map_err(|e| format!("opening {}: {e}", path.display()))?;
let spec = reader.spec();
if spec.channels != 1
|| spec.bits_per_sample != 16
|| spec.sample_format != hound::SampleFormat::Int
{
return Err(format!(
"{}: 16-bit mono integer PCM is required, got {} ch / {} bits / {:?}",
path.display(),
spec.channels,
spec.bits_per_sample,
spec.sample_format
));
}
let rate = SampleRate::new(spec.sample_rate)
.map_err(|e| format!("{}: sample rate: {e}", path.display()))?;
let config = TncConfig::from_profile(rate, profile)
.map_err(|e| format!("{}: config: {e}", path.display()))?;
let mut rx = DefaultTncReceiver::new(config)
.map_err(|e| format!("{}: receiver setup: {e}", path.display()))?;
let mut frames = 0usize;
for sample in reader.samples::<i16>() {
let sample = sample.map_err(|e| format!("reading {}: {e}", path.display()))?;
if rx.push_i16(sample).is_some() {
frames += 1;
}
}
Ok(frames)
}
#[cfg(feature = "fx25")]
fn decode_count_fx25(path: &Path) -> Result<usize, String> {
use yodel::ax25::UiFrame;
use yodel::demodulator::{AfskDemodulator, DemodulatorConfig};
use yodel::fx25::Fx25Receiver;
use yodel::nrzi::NrziDecoder;
let mut reader =
hound::WavReader::open(path).map_err(|e| format!("opening {}: {e}", path.display()))?;
let rate = SampleRate::new(reader.spec().sample_rate)
.map_err(|e| format!("{}: sample rate: {e}", path.display()))?;
let profile = ModemProfile::BELL_202;
let cfg = DemodulatorConfig::new(rate, profile.baud(), profile.tones())
.map_err(|e| format!("{}: config: {e}", path.display()))?;
let mut demod =
AfskDemodulator::new(cfg).map_err(|e| format!("{}: receiver: {e}", path.display()))?;
let mut nrzi = NrziDecoder::default();
let mut rx = Fx25Receiver::<330>::new();
let mut frames = 0usize;
for sample in reader.samples::<i16>() {
let sample = sample.map_err(|e| format!("reading {}: {e}", path.display()))?;
if let Some(line) = demod.push_sample_i16(sample)
&& let Some(Ok(frame)) = rx.push(nrzi.decode(line))
&& UiFrame::parse(frame).is_ok()
{
frames += 1;
}
}
Ok(frames)
}
#[test]
#[ignore = "needs the operator-provided corpus/ WAVs; run with -- --ignored"]
fn corpus_decode_counts_never_regress() {
let corpus = fixture("corpus");
let inputs = presence(&corpus, TRACKS.iter().map(|track| track.file));
if !inputs_ready("corpus/", "operator-provided, gitignored", &inputs) {
return;
}
let mut failures = Vec::new();
let mut measured = 0usize;
for track in TRACKS {
let count = match decode_count(&corpus.join(track.file)) {
Ok(n) => n,
Err(e) => {
failures.push(e);
continue;
}
};
measured += 1;
let ok = if track.exact {
count == track.min_frames
} else {
count >= track.min_frames
};
let op = if track.exact { "==" } else { ">=" };
println!(
"{}: {count} frames (required {op} {}) {}",
track.file,
track.min_frames,
if ok { "OK" } else { "REGRESSION" }
);
if !ok {
failures.push(format!(
"{}: {count} frames, required {op} {}",
track.file, track.min_frames
));
}
}
println!(
"measured {measured} of {} pinned tracks (required >= {MIN_TRACKS_MEASURED})",
TRACKS.len()
);
if measured < MIN_TRACKS_MEASURED {
failures.push(format!(
"measured only {measured} of the {} pinned tracks, required >= \
{MIN_TRACKS_MEASURED}: the thresholds above did not all run",
TRACKS.len()
));
}
assert!(
failures.is_empty(),
"benchmark regression(s): {}",
failures.join("; ")
);
}
#[test]
#[ignore = "needs the operator-provided scratch/bench_noise.wav; run with -- --ignored"]
fn synthetic_noise_row_never_regresses() {
const SYNTHETIC_MIN_FRAMES: usize = 74;
let path = fixture("scratch/bench_noise.wav");
if !inputs_ready(
"synthetic-noise-100",
"operator-provided, gitignored",
&presence_of("scratch/bench_noise.wav", &path),
) {
return;
}
let count = decode_count(&path).expect("decoding the synthetic-noise WAV");
println!("synthetic-noise-100: {count} frames (required >= {SYNTHETIC_MIN_FRAMES})");
assert!(
count >= SYNTHETIC_MIN_FRAMES,
"synthetic-noise regression: {count} frames, required >= {SYNTHETIC_MIN_FRAMES}"
);
}
#[test]
#[cfg(feature = "fx25")]
#[ignore = "needs the operator-provided scratch/bench_noise_fx25.wav; run with -- --ignored"]
fn synthetic_noise_fx25_row_never_regresses() {
const SYNTHETIC_FX25_MIN_FRAMES: usize = 92;
let path = fixture("scratch/bench_noise_fx25.wav");
if !inputs_ready(
"synthetic-noise-100-fx25",
"operator-provided, gitignored",
&presence_of("scratch/bench_noise_fx25.wav", &path),
) {
return;
}
let count = decode_count_fx25(&path).expect("decoding the FX.25 synthetic-noise WAV");
println!("synthetic-noise-100-fx25: {count} frames (required >= {SYNTHETIC_FX25_MIN_FRAMES})");
assert!(
count >= SYNTHETIC_FX25_MIN_FRAMES,
"FX.25 synthetic-noise regression: {count} frames, \
required >= {SYNTHETIC_FX25_MIN_FRAMES}"
);
}
#[test]
#[ignore = "needs the operator-provided scratch/bench_noise_300.wav; run with -- --ignored"]
fn synthetic_noise_300_baud_row_never_regresses() {
const SYNTHETIC_300_MIN_FRAMES: usize = 74;
let path = fixture("scratch/bench_noise_300.wav");
if !inputs_ready(
"synthetic-noise-100-300bd",
"operator-provided, gitignored",
&presence_of("scratch/bench_noise_300.wav", &path),
) {
return;
}
let count = decode_count_with(&path, ModemProfile::HF_APRS_300)
.expect("decoding the 300-baud synthetic-noise WAV");
println!("synthetic-noise-100-300bd: {count} frames (required >= {SYNTHETIC_300_MIN_FRAMES})");
assert!(
count >= SYNTHETIC_300_MIN_FRAMES,
"300-baud synthetic-noise regression: {count} frames, \
required >= {SYNTHETIC_300_MIN_FRAMES}"
);
}
mod presence_rules {
use super::{Availability, MIN_TRACKS_MEASURED, TRACKS, availability};
#[test]
fn every_input_present_measures() {
assert_eq!(
availability(&[("a.wav", true), ("b.wav", true), ("c.wav", true)]),
Availability::AllPresent
);
}
#[test]
fn no_input_present_skips() {
assert_eq!(
availability(&[("a.wav", false), ("b.wav", false), ("c.wav", false)]),
Availability::NonePresent
);
}
#[test]
fn a_partial_input_set_is_never_a_skip() {
for inputs in [
[("a.wav", true), ("b.wav", false)],
[("a.wav", false), ("b.wav", true)],
] {
match availability(&inputs) {
Availability::Partial { present, missing } => {
assert_eq!(present.len(), 1, "{inputs:?}");
assert_eq!(missing.len(), 1, "{inputs:?}");
}
other => panic!("{inputs:?} must not read as {other:?}"),
}
}
}
#[test]
fn the_pinned_track_table_is_three_state_for_every_pattern() {
let names: Vec<&str> = TRACKS.iter().map(|track| track.file).collect();
let mut seen = (0usize, 0usize, 0usize);
for mask in 0u32..(1u32 << TRACKS.len()) {
let inputs: Vec<(&str, bool)> = names
.iter()
.enumerate()
.map(|(i, &name)| (name, (mask & (1 << i)) != 0))
.collect();
let present = mask.count_ones() as usize;
match availability(&inputs) {
Availability::AllPresent => {
assert_eq!(present, names.len(), "mask {mask:#06b}");
seen.0 += 1;
}
Availability::NonePresent => {
assert_eq!(present, 0, "mask {mask:#06b}");
seen.1 += 1;
}
Availability::Partial {
present: p,
missing: m,
} => {
assert_eq!(p.len(), present, "mask {mask:#06b}");
assert_eq!(m.len(), names.len() - present, "mask {mask:#06b}");
assert!(present > 0 && present < names.len(), "mask {mask:#06b}");
seen.2 += 1;
}
}
}
assert_eq!(
seen,
(1, 1, 14),
"of 16 patterns exactly one is all-present, one is none-present, \
and the remaining 14 must fail rather than measure a subset"
);
}
#[test]
fn an_empty_input_list_is_not_a_clean_skip() {
assert_eq!(availability(&[]), Availability::AllPresent);
}
#[test]
fn the_measured_floor_covers_every_pinned_track() {
assert_eq!(
MIN_TRACKS_MEASURED,
TRACKS.len(),
"the floor must count every pinned track; raise it with the table"
);
}
}