Skip to main content

mfsk_core/jt9/
mod.rs

1//! # `jt9` — JT9 decoder and synthesiser
2//!
3//! JT9 is a 9-FSK mode (8 data tones plus 1 sync tone at tone 0) with
4//! a 60-second slot, plain FSK shaping, convolutional r=½ K=32 FEC
5//! with Fano decoding, and the 72-bit JT message payload shared with
6//! JT65. Since the FEC polynomials are identical to WSPR's
7//! (`crate::fec::conv::fano::POLY1`/`POLY2`), the Fano decoder body
8//! is reused unchanged via [`crate::fec::ConvFano232`] — only the
9//! code dimensions differ (72 info + 31 tail → 206 coded bits).
10//!
11//! Sync is carried by 16 symbols at fixed positions in the 85-symbol
12//! frame, each expected on tone 0. That distribution fits the
13//! existing [`crate::core::SyncMode::Block`] variant by expressing
14//! each sync symbol as a length-1 [`crate::core::SyncBlock`]; no new
15//! `SyncMode` variant is required.
16//!
17//! References:
18//! - WSJT-X `lib/jt9_decode.f90`, `lib/jt9sync.f90`, `lib/conv232.f90`,
19//!   `lib/fano232.f90`, `lib/interleave9.f90`
20//!
21//! ## Quick example
22//!
23//! ```no_run
24//! use mfsk_core::jt9::decode_scan_default;
25//!
26//! # let audio: Vec<f32> = vec![];
27//! // `audio` is 720_000 f32 samples at 12 kHz (60 s slot).
28//! for r in decode_scan_default(&audio, 12_000) {
29//!     println!("{:+7.1} Hz  start={:>8} sample  {}",
30//!              r.freq_hz, r.start_sample, r.message);
31//! }
32//! ```
33
34use crate::core::{FrameLayout, ModulationParams, Protocol, ProtocolId, SyncMode};
35use crate::fec::ConvFano232;
36use crate::msg::Jt72Codec;
37
38pub mod baseband;
39pub(crate) mod decode;
40pub(crate) mod demod_bb;
41pub mod interleave;
42pub mod rx;
43pub mod search;
44pub(crate) mod softsym;
45pub mod sync_pattern;
46pub mod tx;
47
48pub use interleave::{deinterleave, deinterleave_llrs, interleave};
49pub use rx::demodulate_aligned;
50pub use search::{SearchParams, SyncCandidate, coarse_search};
51pub use sync_pattern::{JT9_ISYNC, JT9_SYNC_BLOCKS, JT9_SYNC_POSITIONS};
52pub use tx::{encode_channel_symbols, synthesize_audio, synthesize_standard};
53
54/// Top-level convenience: decode a JT9 signal at a known (start_sample,
55/// base_freq) and return the recovered message if Fano converges.
56pub fn decode_at(
57    audio: &[f32],
58    sample_rate: u32,
59    start_sample: usize,
60    base_freq_hz: f32,
61) -> Option<crate::msg::Jt72Message> {
62    use crate::core::{DecodeContext, FecCodec, FecOpts, MessageCodec};
63
64    let llrs = rx::demodulate_aligned(audio, sample_rate, start_sample, base_freq_hz);
65    let codec = ConvFano232;
66    let res = codec.decode_soft(&llrs, &FecOpts::default())?;
67    let mut payload = [0u8; 72];
68    payload.copy_from_slice(&res.info);
69    crate::msg::Jt72Codec::default().unpack(&payload, &DecodeContext::default())
70}
71
72/// One successful JT9 decode with its alignment info.
73#[derive(Clone, Debug)]
74pub struct Jt9Decode {
75    pub message: crate::msg::Jt72Message,
76    pub freq_hz: f32,
77    pub start_sample: usize,
78}
79
80/// Scan an audio buffer for any JT9 frames: runs coarse (freq, time)
81/// search via [`search::coarse_search`] and uses the WSJT-X-faithful
82/// `softsym` pipeline (`downsam9` + `peakdt9` + `symspec2`) on each
83/// candidate in score order, collapsing duplicates that decode to the
84/// same message within ±4 Hz / ±1 symbol.
85pub fn decode_scan(
86    audio: &[f32],
87    sample_rate: u32,
88    nominal_start_sample: usize,
89    params: &search::SearchParams,
90) -> Vec<Jt9Decode> {
91    use crate::core::ModulationParams;
92    let nsps = (sample_rate as f32 * <Jt9 as ModulationParams>::SYMBOL_DT).round() as usize;
93
94    // Collect all coarse candidates above a very low threshold (the score
95    // formula saturates near 1.0 for real JT9 signals regardless of SNR,
96    // so threshold filtering is not effective here).
97    let mut scan_params = *params;
98    scan_params.score_threshold = 0.001;
99    scan_params.max_candidates = 50_000; // no practical cap; NMS below limits processing
100
101    let mut cands = search::coarse_search(audio, sample_rate, nominal_start_sample, &scan_params);
102
103    // Sort by coarse score so high-quality candidates get tried first.
104    // Duplicate-signal suppression happens after decode via `seen`
105    // (message + freq ± 4 Hz + time ± 1 symbol), mirroring WSJT-X
106    // `lib/jt9_decode.f90:157-163` which marks ±22 freq bins `done`
107    // around each successful decode rather than pre-filtering by NMS.
108    cands.sort_unstable_by(|a, b| {
109        b.score
110            .partial_cmp(&a.score)
111            .unwrap_or(std::cmp::Ordering::Equal)
112    });
113    cands.truncate(params.max_candidates.max(32));
114
115    // Build the big FFT once for the whole slot — `downsam9` extracts
116    // one baseband per candidate frequency from this cached spectrum.
117    let big_fft = softsym::AudioFft::build(audio);
118
119    let mut seen: Vec<Jt9Decode> = Vec::new();
120    for c in cands {
121        let Some(d) = decode::decode_at_baseband_with_fft(&big_fft, c.freq_hz) else {
122            continue;
123        };
124        let dup = seen.iter().any(|prev| {
125            prev.message == d.message
126                && (prev.freq_hz - d.freq_hz).abs() <= 4.0
127                && (prev.start_sample as i64 - d.start_sample as i64).abs() <= nsps as i64
128        });
129        if !dup {
130            seen.push(d);
131        }
132    }
133    seen
134}
135
136/// Convenience: scan using [`search::SearchParams::default`].
137pub fn decode_scan_default(audio: &[f32], sample_rate: u32) -> Vec<Jt9Decode> {
138    decode_scan(audio, sample_rate, 0, &search::SearchParams::default())
139}
140
141/// JT9 protocol marker.
142#[derive(Copy, Clone, Debug, Default)]
143pub struct Jt9;
144
145impl ModulationParams for Jt9 {
146    const NTONES: u32 = 9;
147    const BITS_PER_SYMBOL: u32 = 3; // 8 data tones + 1 sync
148    /// Samples per symbol at the 12 kHz pipeline rate. 6912 gives a
149    /// baud rate of 12 000 / 6912 ≈ 1.736 Hz, matching WSJT-X.
150    const NSPS: u32 = 6912;
151    const SYMBOL_DT: f32 = 6912.0 / 12_000.0;
152    const TONE_SPACING_HZ: f32 = 12_000.0 / 6912.0; // ≈ 1.736 Hz
153    /// Data tones are 1..=8; Gray-map the 3 data bits within those
154    /// eight tones. Tone 0 is reserved for sync and isn't part of
155    /// the data constellation, so the Gray map has 8 entries, not 9.
156    const GRAY_MAP: &'static [u8] = &[0, 1, 3, 2, 6, 7, 5, 4];
157    /// No Gaussian shaping — JT9 is plain (square) FSK. Value `0.0`
158    /// signals "no GFSK" to TX synthesisers that check the constant.
159    const GFSK_BT: f32 = 0.0;
160    const GFSK_HMOD: f32 = 1.0;
161    /// Two FFTs per symbol window — standard convention (same as FT8).
162    const NFFT_PER_SYMBOL_FACTOR: u32 = 2;
163    /// Half-symbol coarse-sync step.
164    const NSTEP_PER_SYMBOL: u32 = 2;
165    /// 12 000 / 8 = 1500 Hz baseband. Adequate for the 9-tone
166    /// constellation (9 × 1.736 ≈ 15.6 Hz occupied) plus guard.
167    const NDOWN: u32 = 8;
168}
169
170impl FrameLayout for Jt9 {
171    const N_DATA: u32 = 69;
172    const N_SYNC: u32 = 16;
173    const N_SYMBOLS: u32 = 85;
174    const N_RAMP: u32 = 0;
175    const SYNC_MODE: SyncMode = SyncMode::Block(&JT9_SYNC_BLOCKS);
176    const T_SLOT_S: f32 = 60.0;
177    /// JT9 transmissions start at the top of the minute (0 s into the
178    /// slot). `tx_start` is 0 rather than WSPR's 1 s.
179    const TX_START_OFFSET_S: f32 = 0.0;
180}
181
182impl Protocol for Jt9 {
183    /// Convolutional r=½ K=32 with Layland-Lushbaugh polynomials —
184    /// same as WSPR, different code dimensions (K=72, N=206).
185    type Fec = ConvFano232;
186    /// 72-bit message payload, shared with JT65.
187    type Msg = Jt72Codec;
188    const ID: ProtocolId = ProtocolId::Jt9;
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194    use crate::core::FecCodec;
195
196    #[test]
197    fn jt9_trait_surface() {
198        assert_eq!(<Jt9 as ModulationParams>::NTONES, 9);
199        assert_eq!(<Jt9 as ModulationParams>::BITS_PER_SYMBOL, 3);
200        assert_eq!(<Jt9 as ModulationParams>::NSPS, 6912);
201        assert!((<Jt9 as ModulationParams>::SYMBOL_DT - 0.576).abs() < 1e-3,);
202        assert_eq!(<Jt9 as FrameLayout>::N_SYMBOLS, 85);
203        assert_eq!(<Jt9 as FrameLayout>::N_SYNC, 16);
204        assert_eq!(<Jt9 as FrameLayout>::N_DATA, 69);
205        assert_eq!(<Jt9 as FrameLayout>::T_SLOT_S, 60.0);
206
207        match <Jt9 as FrameLayout>::SYNC_MODE {
208            SyncMode::Block(blocks) => {
209                assert_eq!(blocks.len(), 16);
210                assert_eq!(blocks[0].start_symbol, 0);
211                assert_eq!(blocks[15].start_symbol, 84);
212                for b in blocks {
213                    assert_eq!(b.pattern, &[0u8]);
214                }
215            }
216            SyncMode::Interleaved { .. } => panic!("JT9 must use Block sync"),
217        }
218
219        assert_eq!(<<Jt9 as Protocol>::Fec as FecCodec>::N, 206);
220        assert_eq!(<<Jt9 as Protocol>::Fec as FecCodec>::K, 72);
221    }
222}