#![cfg(feature = "tnc")]
use yodel::ax25::RecoveryPolicy;
use yodel::tnc::{ChainVoting, DefaultTncReceiver, TncConfig};
use yodel::{Bit, SampleRate};
const POLICIES: &[(&str, RecoveryPolicy, ChainVoting)] = &[
("None + voting off", RecoveryPolicy::None, ChainVoting::Off),
("None + voting on", RecoveryPolicy::None, ChainVoting::On),
(
"SingleBitFlip + voting off",
RecoveryPolicy::SingleBitFlip,
ChainVoting::Off,
),
(
"SingleBitFlip + voting on",
RecoveryPolicy::SingleBitFlip,
ChainVoting::On,
),
(
"PreDestuffFlip + voting off",
RecoveryPolicy::PreDestuffFlip,
ChainVoting::Off,
),
(
"PreDestuffFlip + voting on (default)",
RecoveryPolicy::PreDestuffFlip,
ChainVoting::On,
),
];
fn receiver(rate: SampleRate, rec: RecoveryPolicy, vote: ChainVoting) -> DefaultTncReceiver {
let cfg = TncConfig::bell_202(rate)
.expect("bell 202")
.with_recovery(rec)
.with_voting(vote);
DefaultTncReceiver::new(cfg).expect("receiver")
}
struct Lcg(u32);
impl Lcg {
fn next_i16(&mut self) -> i16 {
self.0 = self.0.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
((self.0 >> 16) as i16).wrapping_sub(16_384)
}
}
fn assert_no_frames(rate: SampleRate, samples: &[i16], what: &str) {
for &(name, rec, vote) in POLICIES {
let mut rx = receiver(rate, rec, vote);
let mut frames = 0u32;
for &s in samples {
if rx.push_i16(s).is_some() {
frames += 1;
}
}
assert_eq!(
frames, 0,
"{what}: {name} manufactured {frames} frame(s) from input containing none"
);
}
}
#[test]
fn false_accept_exposure_stays_bounded() {
const MAX_TRIALS_PER_HOUR: f64 = 2_000.0;
let hz = 22_050u32;
let seconds = 60u32;
let rate = SampleRate::new(hz).expect("rate");
let mut rng = Lcg(0x0BAD_F00D);
let samples: Vec<i16> = (0..hz * seconds).map(|_| rng.next_i16()).collect();
let bracket = [POLICIES[0], POLICIES[POLICIES.len() - 1]];
println!(
"false-accept exposure over {seconds} s of full-scale noise @{hz} Hz\n\
{:<38} {:>8} {:>14} {:>16}",
"policy", "trials", "trials/hour", "P(fabricate)/hr"
);
for &(name, rec, vote) in &bracket {
let mut rx = receiver(rate, rec, vote);
let mut emitted = 0u32;
for &s in &samples {
if rx.push_i16(s).is_some() {
emitted += 1;
}
}
let stats = rx.stats();
let trials =
u64::from(stats.fcs_errors) + u64::from(stats.frames_ok) + u64::from(stats.malformed);
let per_hour = trials as f64 * 3600.0 / f64::from(seconds);
let p_fabricate = per_hour / 65_536.0;
println!("{name:<38} {trials:>8} {per_hour:>14.0} {p_fabricate:>16.4}");
assert_eq!(
emitted, 0,
"{name} fabricated {emitted} frame(s) from pure noise"
);
assert!(
per_hour <= MAX_TRIALS_PER_HOUR,
"{name}: {per_hour:.0} closed-candidate FCS trials per hour of dead air \
exceeds the pinned ceiling of {MAX_TRIALS_PER_HOUR:.0}. Each trial is a \
2^-16 chance of manufacturing a frame -- and the repair search multiplies \
it further -- so raising this raises the rate at which users see invented \
data. Tighten the ceiling when it improves; never loosen it."
);
}
}
#[test]
fn white_noise_yields_no_frames() {
for &hz in &[8_000u32, 11_025, 22_050, 44_100, 48_000] {
let rate = SampleRate::new(hz).expect("rate");
let mut rng = Lcg(0x1234_5678 ^ hz);
let samples: Vec<i16> = (0..hz * 30).map(|_| rng.next_i16()).collect();
assert_no_frames(rate, &samples, &format!("white noise @{hz}"));
}
}
#[test]
fn silence_and_unmodulated_tone_yield_no_frames() {
let rate = SampleRate::new(44_100).expect("rate");
let silence = vec![0i16; 44_100 * 5];
assert_no_frames(rate, &silence, "silence");
let tone: Vec<i16> = (0..44_100u32 * 5)
.map(|i| {
let phase = i as f64 * 1200.0 * std::f64::consts::TAU / 44_100.0;
(phase.sin() * 12_000.0) as i16
})
.collect();
assert_no_frames(rate, &tone, "unmodulated mark tone");
}
#[test]
fn random_bitstream_yields_no_frames() {
#[cfg(feature = "mod")]
{
use yodel::{Modulator, ModulatorConfig};
let rate = SampleRate::new(44_100).expect("rate");
let mut rng = Lcg(0xC0FF_EE01);
let bits: Vec<Bit> = (0..40_000)
.map(|_| {
if rng.next_i16() >= 0 {
Bit::One
} else {
Bit::Zero
}
})
.collect();
let cfg = ModulatorConfig::bell_202(rate).expect("modulator config");
let samples: Vec<i16> = Modulator::new(cfg).i16_samples(bits.into_iter()).collect();
assert_no_frames(rate, &samples, "random unframed bitstream");
}
}
#[test]
fn heavily_corrupted_frame_is_not_repaired_into_a_different_frame() {
#[cfg(all(feature = "mod", feature = "alloc"))]
{
use yodel::aprs::{AprsPacket, Status};
use yodel::ax25::Address;
use yodel::tnc::TncTransmitter;
let rate = SampleRate::new(44_100).expect("rate");
let tx = TncTransmitter::new(TncConfig::bell_202(rate).expect("config"));
let packet = AprsPacket::Status(Status {
text: b"specificity probe payload",
});
let clean: Vec<i16> = tx
.transmit_to_vec_i16(
&packet,
Address::new(b"APRS", 0).expect("dest"),
Address::new(b"N0CALL", 1).expect("src"),
&[],
)
.expect("transmit");
let mut rx = receiver(rate, RecoveryPolicy::PreDestuffFlip, ChainVoting::On);
let decoded = clean.iter().filter(|&&s| rx.push_i16(s).is_some()).count();
assert_eq!(decoded, 1, "clean control signal must decode exactly once");
let mut rng = Lcg(0x5EED_5EED);
let mut wrecked = clean.clone();
let (lo, hi) = (wrecked.len() / 3, wrecked.len() * 2 / 3);
for s in &mut wrecked[lo..hi] {
*s = rng.next_i16();
}
for &(name, rec, vote) in POLICIES {
let mut rx = receiver(rate, rec, vote);
for &s in &wrecked {
if let Some(frame) = rx.push_i16(s) {
assert_eq!(
frame.info(),
b">specificity probe payload",
"{name}: repaired a destroyed burst into a DIFFERENT frame"
);
}
}
}
}
}