Skip to main content

mfsk_core/jt65/
mod.rs

1//! # `jt65` — JT65 decoder and synthesiser
2//!
3//! JT65 is the classic EME (moonbounce) / weak-signal mode that
4//! WSJT-X inherited from the original WSJT. It uses:
5//! - **65-FSK** modulation (1 sync tone at index 0 + 64 data tones
6//!   at indices 2..=65; index 1 is unused). Plain FSK, no GFSK.
7//! - **RS(63, 12) over GF(2^6)** for error correction (51 parity
8//!   symbols, corrects up to 25 symbol errors). Implemented in
9//!   [`crate::fec::Rs63_12`].
10//! - **72-bit JT message payload** packed into 12 × 6-bit symbols —
11//!   the same layout as JT9 ([`crate::msg::Jt72Codec`]).
12//! - **Pseudo-random distributed sync**: a fixed 126-bit pattern
13//!   (`nprc`) marks 63 positions that carry tone 0 (sync) and 63
14//!   that carry Gray-coded data symbols. Expressed in our abstraction
15//!   as 63 length-1 `SyncBlock` entries under the existing
16//!   `SyncMode::Block` variant — no new `SyncMode` case required.
17//!
18//! Only the **JT65A** sub-mode (tone spacing = baud ≈ 2.69 Hz) is
19//! currently wired. JT65B and JT65C differ by a tone-spacing
20//! multiplier (2×, 4×) and can be added as separate ZSTs sharing
21//! every other piece.
22//!
23//! References:
24//! - WSJT-X `lib/jt65sim.f90`, `lib/setup65.f90`, `lib/interleave63.f90`,
25//!   `lib/graycode65.f90`, `lib/wrapkarn.c`
26//!
27//! ## Quick example
28//!
29//! ```no_run
30//! use mfsk_core::jt65::decode_scan_default;
31//!
32//! # let audio: Vec<f32> = vec![];
33//! // `audio` is 720_000 f32 samples at 12 kHz (60 s slot).
34//! for r in decode_scan_default(&audio, 12_000) {
35//!     println!("{:+7.1} Hz  start={:>8} sample  {}",
36//!              r.freq_hz, r.start_sample, r.message);
37//! }
38//! ```
39//!
40//! ## Erasure-aware decode
41//!
42//! For very weak signals, JT65 benefits from feeding per-symbol
43//! confidence into Reed-Solomon as *erasures*. Each erasure lets RS
44//! correct one more symbol than the hard-error bound
45//! (`2·errors + erasures ≤ 51`). Use [`decode_at_with_erasures`]:
46//!
47//! ```no_run
48//! use mfsk_core::jt65::decode_at_with_erasures;
49//!
50//! # let audio: Vec<f32> = vec![];
51//! # let (start_sample, freq_hz) = (0, 1270.0);
52//! // Try 0 → 8 → 16 → 24 → 32 erasures in order; return the first
53//! // budget that unpacks into a valid message.
54//! let msg = decode_at_with_erasures(
55//!     &audio, 12_000, start_sample, freq_hz,
56//!     &[0, 8, 16, 24, 32],
57//! );
58//! ```
59
60use crate::core::{FrameLayout, ModulationParams, Protocol, ProtocolId, SyncMode};
61use crate::fec::Rs63_12;
62use crate::msg::Jt72Codec;
63
64pub mod gray;
65pub mod interleave;
66pub mod rx;
67pub mod search;
68pub mod sync_pattern;
69pub mod tx;
70
71pub use gray::{gray6, inv_gray6};
72pub use interleave::{deinterleave, interleave};
73pub use rx::{demodulate_aligned, demodulate_aligned_with_confidence};
74pub use sync_pattern::{JT65_DATA_POSITIONS, JT65_NPRC, JT65_SYNC_BLOCKS, JT65_SYNC_POSITIONS};
75pub use tx::{encode_channel_symbols, synthesize_audio, synthesize_standard};
76
77/// Top-level: decode a JT65 signal at a known (start_sample, base_freq)
78/// and return the recovered message if RS succeeds. Mirrors the shape of
79/// `mfsk_core::jt9::decode_at`.
80pub fn decode_at(
81    audio: &[f32],
82    sample_rate: u32,
83    start_sample: usize,
84    base_freq_hz: f32,
85) -> Option<crate::msg::Jt72Message> {
86    use crate::core::{DecodeContext, MessageCodec};
87
88    let received = rx::demodulate_aligned(audio, sample_rate, start_sample, base_freq_hz)?;
89    let rs = Rs63_12::new();
90    let (info, _nerr) = rs.decode_jt65(&received)?;
91    let mut payload = [0u8; 72];
92    for (i, bit) in payload.iter_mut().enumerate() {
93        let word = info[i / 6];
94        let shift = 5 - (i % 6);
95        *bit = (word >> shift) & 1;
96    }
97    crate::msg::Jt72Codec::default().unpack(&payload, &DecodeContext::default())
98}
99
100/// Decode a JT65 signal at a known alignment, trying progressively
101/// larger erasure counts until Reed-Solomon converges or the bound
102/// is exhausted. Unlike [`decode_at`], this method exploits
103/// per-symbol confidence from the demodulator: symbols with the
104/// smallest (best − runner-up) margin are flagged as erasures, which
105/// doubles the correctable error count compared to the plain
106/// hard-decision bound.
107///
108/// `attempts` is a slice of erasure counts to try in order. A
109/// reasonable default is `&[0, 8, 16, 24, 32]`: zero-erasure first
110/// (fastest when the channel is clean) and then growing erasure
111/// budgets for lower-SNR signals. Returns the first decode that
112/// unpacks into a valid [`crate::msg::jt72::Jt72Message`].
113pub fn decode_at_with_erasures(
114    audio: &[f32],
115    sample_rate: u32,
116    start_sample: usize,
117    base_freq_hz: f32,
118    attempts: &[usize],
119) -> Option<crate::msg::Jt72Message> {
120    use crate::core::{DecodeContext, MessageCodec};
121
122    let (symbols, conf) =
123        rx::demodulate_aligned_with_confidence(audio, sample_rate, start_sample, base_freq_hz)?;
124    // Build an ordering of symbol positions from least → most
125    // confident; the caller's erasure budget eats from the start.
126    let mut order: Vec<usize> = (0..63).collect();
127    order.sort_by(|&a, &b| {
128        conf[a]
129            .partial_cmp(&conf[b])
130            .unwrap_or(std::cmp::Ordering::Equal)
131    });
132
133    let rs = Rs63_12::new();
134    let codec = crate::msg::Jt72Codec::default();
135    let ctx = DecodeContext::default();
136
137    for &n_eras in attempts {
138        let n_eras = n_eras.min(51); // hard upper bound = NROOTS
139        let eras: Vec<u32> = order.iter().take(n_eras).map(|&i| i as u32).collect();
140
141        // Decode_jt65_erasures takes positions in the WSJT `sent[]` layout;
142        // our `symbols` array is already in RS-codeword order (after
143        // de-interleave + de-Gray). Those positions match the WSJT
144        // data half (symbols 51..=62 of sent[]), so pass them through.
145        // Build a `sent[]`-shaped array by placing our symbols into the
146        // data section; parity values are unknown, so the caller can
147        // leave them as-is — the decoder will treat them as zeros.
148        let mut sent = [0u8; 63];
149        // Map: symbols[i] (i=0..=62) → sent[51 + 12 - 1 - (i %12)] is wrong.
150        // Actually our `symbols` represents the 63-symbol RS codeword
151        // in *native Karn order* (the canonical [data || parity] layout)
152        // after de-interleave + inverse Gray. WSJT-X's decode_rs wants
153        // the reversed layout, but our Rs63_12 wrappers do that
154        // translation. The simplest path: re-wrap via the JT65 encoder
155        // convention — we already have sent-layout input in the
156        // existing decode path, so mirror that here.
157        //
158        // Looking at the original decode_at: it passes `symbols` (RS
159        // codeword order) to `rs.decode_jt65(&symbols)`. So `symbols`
160        // IS the WSJT sent-layout array. We can pass erasure indices
161        // directly in that layout.
162        sent.copy_from_slice(&symbols);
163        if let Some((info, _nerr)) = rs.decode_jt65_erasures(&sent, &eras) {
164            let mut payload = [0u8; 72];
165            for (i, bit) in payload.iter_mut().enumerate() {
166                let word = info[i / 6];
167                let shift = 5 - (i % 6);
168                *bit = (word >> shift) & 1;
169            }
170            if let Some(msg) = codec.unpack(&payload, &ctx) {
171                return Some(msg);
172            }
173        }
174    }
175    None
176}
177
178/// One successful JT65 decode with its alignment info.
179#[derive(Clone, Debug)]
180pub struct Jt65Decode {
181    pub message: crate::msg::Jt72Message,
182    pub freq_hz: f32,
183    pub start_sample: usize,
184}
185
186/// Scan an audio buffer for JT65 frames at any (freq, time) within
187/// the search window: runs [`search::coarse_search`] and tries
188/// [`decode_at`] on each candidate in score order, collapsing
189/// duplicate decodes (same message ±2 Hz / ±1 symbol).
190pub fn decode_scan(
191    audio: &[f32],
192    sample_rate: u32,
193    nominal_start_sample: usize,
194    params: &search::SearchParams,
195) -> Vec<Jt65Decode> {
196    use crate::core::ModulationParams;
197    let nsps = (sample_rate as f32 * <Jt65 as ModulationParams>::SYMBOL_DT).round() as usize;
198    let cands = search::coarse_search(audio, sample_rate, nominal_start_sample, params);
199    let mut seen: Vec<Jt65Decode> = Vec::new();
200    for c in cands {
201        let Some(msg) = decode_at(audio, sample_rate, c.start_sample, c.freq_hz) else {
202            continue;
203        };
204        let dup = seen.iter().any(|prev| {
205            prev.message == msg
206                && (prev.freq_hz - c.freq_hz).abs() <= 2.0
207                && (prev.start_sample as i64 - c.start_sample as i64).abs() <= nsps as i64
208        });
209        if !dup {
210            seen.push(Jt65Decode {
211                message: msg,
212                freq_hz: c.freq_hz,
213                start_sample: c.start_sample,
214            });
215        }
216    }
217    seen
218}
219
220pub fn decode_scan_default(audio: &[f32], sample_rate: u32) -> Vec<Jt65Decode> {
221    decode_scan(audio, sample_rate, 0, &search::SearchParams::default())
222}
223
224/// JT65A protocol marker.
225///
226/// The `A` sub-mode uses the native baud ≈ 2.69 Hz tone spacing
227/// (12 000 / 4460 Hz). B and C modes share everything else but
228/// apply 2×/4× multipliers to the spacing.
229#[derive(Copy, Clone, Debug, Default)]
230pub struct Jt65;
231
232impl ModulationParams for Jt65 {
233    /// 66 = max tone index (65) + 1. Tones 2..=65 are the 64 data
234    /// tones; tone 0 is sync; tone 1 is unused (a single-slot gap
235    /// above the sync tone, a quirk of the WSJT-X tone numbering).
236    const NTONES: u32 = 66;
237    const BITS_PER_SYMBOL: u32 = 6;
238    /// 4460 samples/symbol at 12 kHz gives baud ≈ 2.6906 Hz — the
239    /// canonical rounded value WSJT-X uses internally derives from
240    /// 11 025 / 4096 but the integer-sample convention in our
241    /// pipeline is NSPS.
242    const NSPS: u32 = 4460;
243    const SYMBOL_DT: f32 = 4460.0 / 12_000.0;
244    const TONE_SPACING_HZ: f32 = 12_000.0 / 4460.0; // ≈ 2.6906 Hz
245    /// No Gray map here — Gray is applied at the *symbol* level
246    /// (6-bit) in [`gray::gray6`], not at the FSK-tone level. A
247    /// minimal identity map satisfies the trait's `GRAY_MAP.len()
248    /// == NTONES` invariant.
249    const GRAY_MAP: &'static [u8] = &IDENTITY_66;
250    const GFSK_BT: f32 = 0.0; // plain FSK
251    const GFSK_HMOD: f32 = 1.0;
252    const NFFT_PER_SYMBOL_FACTOR: u32 = 2;
253    const NSTEP_PER_SYMBOL: u32 = 2;
254    /// 12 000 / 4 = 3000 Hz baseband (enough for the 65-tone span).
255    const NDOWN: u32 = 4;
256}
257
258const IDENTITY_66: [u8; 66] = {
259    let mut m = [0u8; 66];
260    let mut i = 0usize;
261    while i < 66 {
262        m[i] = i as u8;
263        i += 1;
264    }
265    m
266};
267
268impl FrameLayout for Jt65 {
269    const N_DATA: u32 = 63;
270    const N_SYNC: u32 = 63;
271    const N_SYMBOLS: u32 = 126;
272    const N_RAMP: u32 = 0;
273    const SYNC_MODE: SyncMode = SyncMode::Block(&JT65_SYNC_BLOCKS);
274    /// 46.8-second frame, scheduled in 60-second slots with a few
275    /// seconds of leading silence — matches WSJT-X's JT65 slot.
276    const T_SLOT_S: f32 = 60.0;
277    const TX_START_OFFSET_S: f32 = 0.0;
278}
279
280impl Protocol for Jt65 {
281    /// Reed-Solomon (63, 12) over GF(2^6). Does NOT implement
282    /// `FecCodec` (bit-LLR oriented) — jt65-core's decode path
283    /// bypasses the generic pipeline and calls the symbol-level
284    /// API directly. Declared here so the protocol's FEC intent
285    /// is still visible in the trait surface.
286    type Fec = Rs63_12;
287    /// 72-bit message payload (12 × 6-bit words), shared with JT9.
288    type Msg = Jt72Codec;
289    const ID: ProtocolId = ProtocolId::Jt65;
290}
291
292#[cfg(test)]
293mod tests {
294    use super::*;
295    use crate::msg::Jt72Message;
296
297    #[test]
298    fn erasure_assisted_decode_recovers_under_moderate_noise() {
299        // Clean synth gets decoded by plain `decode_at`; erasure path
300        // is a strict superset so it should also work (trying 0 first).
301        let freq = 1270.0;
302        let audio = synthesize_standard("CQ", "K1ABC", "FN42", 12_000, freq, 0.3).expect("synth");
303        let msg = decode_at_with_erasures(&audio, 12_000, 0, freq, &[0, 8, 16, 24, 32])
304            .expect("erasure-aware path must decode clean synth");
305        assert!(matches!(
306            msg,
307            Jt72Message::Standard { ref call1, ref call2, ref grid_or_report }
308                if call1 == "CQ" && call2 == "K1ABC" && grid_or_report == "FN42"
309        ));
310    }
311
312    #[test]
313    fn jt65_trait_surface() {
314        assert_eq!(<Jt65 as ModulationParams>::NTONES, 66);
315        assert_eq!(<Jt65 as ModulationParams>::BITS_PER_SYMBOL, 6);
316        assert_eq!(<Jt65 as ModulationParams>::NSPS, 4460);
317        assert_eq!(<Jt65 as FrameLayout>::N_SYMBOLS, 126);
318        assert_eq!(<Jt65 as FrameLayout>::N_DATA, 63);
319        assert_eq!(<Jt65 as FrameLayout>::N_SYNC, 63);
320        match <Jt65 as FrameLayout>::SYNC_MODE {
321            SyncMode::Block(blocks) => {
322                assert_eq!(blocks.len(), 63);
323                for b in blocks {
324                    assert_eq!(b.pattern, &[0u8]);
325                }
326            }
327            SyncMode::Interleaved { .. } => panic!("JT65 must use Block sync"),
328        }
329        // RS(63, 12) doesn't implement FecCodec — we only verify the
330        // associated-type wiring compiles by spelling the path out.
331        let _fec = Rs63_12::default();
332    }
333}