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 interleave;
39pub mod rx;
40pub mod search;
41pub mod sync_pattern;
42pub mod tx;
43
44pub use interleave::{deinterleave, deinterleave_llrs, interleave};
45pub use rx::demodulate_aligned;
46pub use search::{SearchParams, SyncCandidate, coarse_search};
47pub use sync_pattern::{JT9_ISYNC, JT9_SYNC_BLOCKS, JT9_SYNC_POSITIONS};
48pub use tx::{encode_channel_symbols, synthesize_audio, synthesize_standard};
49
50/// Top-level convenience: decode a JT9 signal at a known (start_sample,
51/// base_freq) and return the recovered message if Fano converges.
52pub fn decode_at(
53    audio: &[f32],
54    sample_rate: u32,
55    start_sample: usize,
56    base_freq_hz: f32,
57) -> Option<crate::msg::Jt72Message> {
58    use crate::core::{DecodeContext, FecCodec, FecOpts, MessageCodec};
59
60    let llrs = rx::demodulate_aligned(audio, sample_rate, start_sample, base_freq_hz);
61    let codec = ConvFano232;
62    let res = codec.decode_soft(&llrs, &FecOpts::default())?;
63    let mut payload = [0u8; 72];
64    payload.copy_from_slice(&res.info);
65    crate::msg::Jt72Codec::default().unpack(&payload, &DecodeContext::default())
66}
67
68/// One successful JT9 decode with its alignment info.
69#[derive(Clone, Debug)]
70pub struct Jt9Decode {
71    pub message: crate::msg::Jt72Message,
72    pub freq_hz: f32,
73    pub start_sample: usize,
74}
75
76/// Scan an audio buffer for any JT9 frames: runs coarse (freq, time)
77/// search via [`search::coarse_search`] and tries [`decode_at`] on
78/// each candidate in score order, collapsing duplicates that decode
79/// to the same message within ±2 Hz / ±1 symbol.
80pub fn decode_scan(
81    audio: &[f32],
82    sample_rate: u32,
83    nominal_start_sample: usize,
84    params: &search::SearchParams,
85) -> Vec<Jt9Decode> {
86    use crate::core::ModulationParams;
87    let nsps = (sample_rate as f32 * <Jt9 as ModulationParams>::SYMBOL_DT).round() as usize;
88    let cands = search::coarse_search(audio, sample_rate, nominal_start_sample, params);
89    let mut seen: Vec<Jt9Decode> = Vec::new();
90    for c in cands {
91        let Some(msg) = decode_at(audio, sample_rate, c.start_sample, c.freq_hz) else {
92            continue;
93        };
94        let dup = seen.iter().any(|prev| {
95            prev.message == msg
96                && (prev.freq_hz - c.freq_hz).abs() <= 2.0
97                && (prev.start_sample as i64 - c.start_sample as i64).abs() <= nsps as i64
98        });
99        if !dup {
100            seen.push(Jt9Decode {
101                message: msg,
102                freq_hz: c.freq_hz,
103                start_sample: c.start_sample,
104            });
105        }
106    }
107    seen
108}
109
110/// Convenience: scan using [`search::SearchParams::default`].
111pub fn decode_scan_default(audio: &[f32], sample_rate: u32) -> Vec<Jt9Decode> {
112    decode_scan(audio, sample_rate, 0, &search::SearchParams::default())
113}
114
115/// JT9 protocol marker.
116#[derive(Copy, Clone, Debug, Default)]
117pub struct Jt9;
118
119impl ModulationParams for Jt9 {
120    const NTONES: u32 = 9;
121    const BITS_PER_SYMBOL: u32 = 3; // 8 data tones + 1 sync
122    /// Samples per symbol at the 12 kHz pipeline rate. 6912 gives a
123    /// baud rate of 12 000 / 6912 ≈ 1.736 Hz, matching WSJT-X.
124    const NSPS: u32 = 6912;
125    const SYMBOL_DT: f32 = 6912.0 / 12_000.0;
126    const TONE_SPACING_HZ: f32 = 12_000.0 / 6912.0; // ≈ 1.736 Hz
127    /// Data tones are 1..=8; Gray-map the 3 data bits within those
128    /// eight tones. Tone 0 is reserved for sync and isn't part of
129    /// the data constellation, so the Gray map has 8 entries, not 9.
130    const GRAY_MAP: &'static [u8] = &[0, 1, 3, 2, 6, 7, 5, 4];
131    /// No Gaussian shaping — JT9 is plain (square) FSK. Value `0.0`
132    /// signals "no GFSK" to TX synthesisers that check the constant.
133    const GFSK_BT: f32 = 0.0;
134    const GFSK_HMOD: f32 = 1.0;
135    /// Two FFTs per symbol window — standard convention (same as FT8).
136    const NFFT_PER_SYMBOL_FACTOR: u32 = 2;
137    /// Half-symbol coarse-sync step.
138    const NSTEP_PER_SYMBOL: u32 = 2;
139    /// 12 000 / 8 = 1500 Hz baseband. Adequate for the 9-tone
140    /// constellation (9 × 1.736 ≈ 15.6 Hz occupied) plus guard.
141    const NDOWN: u32 = 8;
142}
143
144impl FrameLayout for Jt9 {
145    const N_DATA: u32 = 69;
146    const N_SYNC: u32 = 16;
147    const N_SYMBOLS: u32 = 85;
148    const N_RAMP: u32 = 0;
149    const SYNC_MODE: SyncMode = SyncMode::Block(&JT9_SYNC_BLOCKS);
150    const T_SLOT_S: f32 = 60.0;
151    /// JT9 transmissions start at the top of the minute (0 s into the
152    /// slot). `tx_start` is 0 rather than WSPR's 1 s.
153    const TX_START_OFFSET_S: f32 = 0.0;
154}
155
156impl Protocol for Jt9 {
157    /// Convolutional r=½ K=32 with Layland-Lushbaugh polynomials —
158    /// same as WSPR, different code dimensions (K=72, N=206).
159    type Fec = ConvFano232;
160    /// 72-bit message payload, shared with JT65.
161    type Msg = Jt72Codec;
162    const ID: ProtocolId = ProtocolId::Jt9;
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168    use crate::core::FecCodec;
169
170    #[test]
171    fn jt9_trait_surface() {
172        assert_eq!(<Jt9 as ModulationParams>::NTONES, 9);
173        assert_eq!(<Jt9 as ModulationParams>::BITS_PER_SYMBOL, 3);
174        assert_eq!(<Jt9 as ModulationParams>::NSPS, 6912);
175        assert!((<Jt9 as ModulationParams>::SYMBOL_DT - 0.576).abs() < 1e-3,);
176        assert_eq!(<Jt9 as FrameLayout>::N_SYMBOLS, 85);
177        assert_eq!(<Jt9 as FrameLayout>::N_SYNC, 16);
178        assert_eq!(<Jt9 as FrameLayout>::N_DATA, 69);
179        assert_eq!(<Jt9 as FrameLayout>::T_SLOT_S, 60.0);
180
181        match <Jt9 as FrameLayout>::SYNC_MODE {
182            SyncMode::Block(blocks) => {
183                assert_eq!(blocks.len(), 16);
184                assert_eq!(blocks[0].start_symbol, 0);
185                assert_eq!(blocks[15].start_symbol, 84);
186                for b in blocks {
187                    assert_eq!(b.pattern, &[0u8]);
188                }
189            }
190            SyncMode::Interleaved { .. } => panic!("JT9 must use Block sync"),
191        }
192
193        assert_eq!(<<Jt9 as Protocol>::Fec as FecCodec>::N, 206);
194        assert_eq!(<<Jt9 as Protocol>::Fec as FecCodec>::K, 72);
195    }
196}