1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
//! Parallel slicer bank: tilted-channel decode and duplicate-emission
//! tests through the public `TncReceiver` path.
//!
//! A pre- or de-emphasized channel attenuates one of the two AFSK tones
//! by several dB. The receiver's swept space-gain slicer bank must decode
//! a full frame with either tone ~5 dB down, and a frame decodable by
//! several chains at once must be emitted exactly once.
use yodel::SampleRate;
use yodel::aprs::{AprsPacket, Latitude, Longitude, Position, Symbol};
use yodel::ax25::Address;
use yodel::geo::Ambiguity;
use yodel::tnc::{DefaultTncReceiver, SpaceGainSweep, TncConfig, TncReceiver, TncTransmitter};
const SR: u32 = 48_000;
fn config() -> TncConfig {
TncConfig::bell_202(SampleRate::new(SR).unwrap()).unwrap()
}
fn packet() -> AprsPacket<'static> {
AprsPacket::Position(Position {
ambiguity: Ambiguity::EXACT,
latitude: Latitude::new(34 * 6000 + 1234).unwrap(),
longitude: Longitude::new(-(118 * 6000 + 4321)).unwrap(),
symbol: Symbol::CAR,
messaging: false,
compressed: false,
extension: None,
comment: b"slicer bank test",
})
}
/// Transmits one frame with per-tone amplitude scaling (`num/32`; 18/32
/// is about -5 dB) applied to the modulated samples according to the
/// instantaneous bit, and counts decoded frames.
///
/// Scaling the composite waveform per sample is a crude tilt model, but
/// mirrors what an emphasis network does to the tone amplitudes closely
/// enough for a decode/no-decode assertion.
fn decode_count_tilted(mark_num: i32, space_num: i32) -> u32 {
let cfg = config();
let tx = TncTransmitter::new(cfg);
let mut rx: DefaultTncReceiver = TncReceiver::new(cfg).unwrap();
let dest = Address::new(b"APRS", 0).unwrap();
let src = Address::new(b"N0CALL", 7).unwrap();
let mut info_buf = [0u8; 330];
let mut frame_buf = [0u8; 330];
let samples = tx
.transmit_i16(&packet(), dest, src, &[], &mut info_buf, &mut frame_buf)
.unwrap();
// Track the dominant tone by the sample-to-sample phase step of the
// (continuous-phase) modulator output: approximate by attenuating
// through a one-pole split is overkill — instead, tilt by frequency
// using a simple first-order high/low shelf: y[n] = a·x[n] + b·x[n-1]
// chosen so 1200 Hz and 2200 Hz see the requested amplitudes.
// For a decode/no-decode test a two-tap FIR suffices: with
// y[n] = g0·x[n] − g1·x[n−1], the magnitude response at f is
// sqrt(g0² + g1² − 2·g0·g1·cos(2πf/Fs)), monotone in f, so solving the
// two-tap gains for the two tone amplitudes realizes the tilt.
let (g0, g1) = solve_two_tap(mark_num as f64 / 32.0, space_num as f64 / 32.0);
let mut prev = 0.0f64;
let mut frames = 0u32;
for s in samples {
let x = s as f64;
let y = g0 * x + g1 * prev;
prev = x;
let tilted = y.clamp(-32768.0, 32767.0) as i16;
if rx.push_i16(tilted).is_some() {
frames += 1;
}
}
// Flush with a bit of silence so trailing state settles.
for _ in 0..(SR / 100) {
if rx.push_i16(0).is_some() {
frames += 1;
}
}
frames
}
/// Solves `y[n] = g0·x[n] + g1·x[n−1]` for gains `a_mark` at 1200 Hz and
/// `a_space` at 2200 Hz (both at 48 kHz).
fn solve_two_tap(a_mark: f64, a_space: f64) -> (f64, f64) {
// |H(f)|² = g0² + g1² + 2·g0·g1·cos(w). Two equations, two unknowns;
// solve numerically by bisection on the ratio r = g1/g0.
let wm = 2.0 * core::f64::consts::PI * 1200.0 / SR as f64;
let ws = 2.0 * core::f64::consts::PI * 2200.0 / SR as f64;
let target = (a_space / a_mark).powi(2);
let ratio_at = |r: f64| {
let num = 1.0 + r * r + 2.0 * r * ws.cos();
let den = 1.0 + r * r + 2.0 * r * wm.cos();
num / den
};
let (mut lo, mut hi) = (-0.999f64, 0.999f64);
for _ in 0..200 {
let mid = 0.5 * (lo + hi);
// ratio_at is monotone decreasing in r on (-1, 1) for wm < ws.
if ratio_at(mid) > target {
lo = mid;
} else {
hi = mid;
}
}
let r = 0.5 * (lo + hi);
let g0 = a_mark / (1.0 + r * r + 2.0 * r * wm.cos()).sqrt();
(g0, g0 * r)
}
#[test]
fn flat_channel_decodes_once() {
assert_eq!(decode_count_tilted(32, 32), 1);
}
#[test]
fn space_tone_attenuated_5db_decodes() {
// De-emphasized channel: 2200 Hz space ~5 dB below mark.
assert_eq!(decode_count_tilted(32, 18), 1);
}
#[test]
fn mark_tone_attenuated_5db_decodes() {
// Pre-emphasized channel: 1200 Hz mark ~5 dB below space.
assert_eq!(decode_count_tilted(18, 32), 1);
}
/// A clean flat-channel frame is decodable by several adjacent-gain
/// chains simultaneously; the merge must still emit it exactly once
/// (asserted by `flat_channel_decodes_once` above with the full default
/// sweep, and here with a sweep of two identical unity chains, which by
/// construction decode the same frame on the same sample).
#[test]
fn duplicate_chains_emit_once() {
let sweep = SpaceGainSweep::new(&[256, 256]).unwrap();
let cfg = config().with_space_gain_sweep(sweep);
let tx = TncTransmitter::new(cfg);
let mut rx: DefaultTncReceiver = TncReceiver::new(cfg).unwrap();
let dest = Address::new(b"APRS", 0).unwrap();
let src = Address::new(b"N0CALL", 7).unwrap();
let mut info_buf = [0u8; 330];
let mut frame_buf = [0u8; 330];
let samples = tx
.transmit_i16(&packet(), dest, src, &[], &mut info_buf, &mut frame_buf)
.unwrap();
let mut frames = 0u32;
for s in samples {
if rx.push_i16(s).is_some() {
frames += 1;
}
}
assert_eq!(frames, 1, "duplicate chain emissions not merged");
assert_eq!(rx.stats().frames_ok, 1);
}
/// Sweep validation: empty, oversized, and zero-gain sweeps are rejected
/// with typed errors.
#[test]
fn sweep_validation() {
assert!(SpaceGainSweep::new(&[]).is_err());
assert!(SpaceGainSweep::new(&[256; 10]).is_err());
assert!(SpaceGainSweep::new(&[256, 0, 128]).is_err());
let ok = SpaceGainSweep::new(&[128, 256, 512]).unwrap();
assert_eq!(ok.gains(), &[128, 256, 512]);
assert_eq!(ok.len(), 3);
assert!(!ok.is_empty());
}