Skip to main content

orion_sdr/codec/
ft8.rs

1// Copyright (c) 2026 G & R Associates LLC
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4// FT8 channel codec: ties together CRC-14, LDPC(174,91), and Gray code.
5//
6// Encode path:  77-bit payload → CRC → LDPC → Gray → Ft8Frame (58 tone indices)
7// Decode path:  Ft8Frame → inverse Gray → LLRs → LDPC → CRC check → 77-bit payload
8
9use crate::codec::crc::{ft8_add_crc, ft8_crc14, ft8_extract_crc};
10use crate::codec::gray::{gray8_decode, gray8_encode};
11use crate::codec::ldpc::{self, ldpc_decode_soft, ldpc_encode};
12use crate::message::{CallsignHashTable, Ft8Message, unpack77};
13use crate::modulate::Ft8Frame;
14use crate::modulate::ft4::{FT4_FRAME_LEN, FT4_TONE_SPACING_HZ};
15use crate::modulate::ft8::{FT8_DATA_SYMS, FT8_FRAME_LEN, FT8_TONE_SPACING_HZ};
16use crate::sync::{ft4_sync, ft8_sync};
17use num_complex::Complex32 as C32;
18
19/// 77-bit FT8 payload packed into 10 bytes (MSB first; bits 77..79 of byte 9 are zero).
20pub type Ft8Bits = [u8; 10];
21
22/// FT8 channel encoder/decoder.
23pub struct Ft8Codec;
24
25impl Ft8Codec {
26    /// Encode a 77-bit payload into an `Ft8Frame` of 58 Gray-coded tone indices.
27    ///
28    /// Steps: payload → CRC-14 appended → LDPC(174,91) → Gray code → 58 tones.
29    pub fn encode(payload: &Ft8Bits) -> Ft8Frame {
30        // 1. Append CRC-14 to get 91-bit a91
31        let mut a91 = [0u8; ldpc::K_BYTES];
32        ft8_add_crc(payload, &mut a91);
33
34        // 2. LDPC encode: 91 bits → 174-bit codeword
35        let mut codeword = [0u8; ldpc::N_BYTES];
36        ldpc_encode(&a91, &mut codeword);
37
38        // 3. Extract 174 bits, group into 58 × 3-bit words, Gray-encode each
39        let mut tones = [0u8; FT8_DATA_SYMS];
40        let mut mask: u8 = 0x80;
41        let mut byte_idx = 0usize;
42
43        for tone in tones.iter_mut() {
44            let mut bits3: u8 = 0;
45            for bit_pos in (0u8..3).rev() {
46                if codeword[byte_idx] & mask != 0 {
47                    bits3 |= 1 << bit_pos;
48                }
49                mask >>= 1;
50                if mask == 0 {
51                    mask = 0x80;
52                    byte_idx += 1;
53                }
54            }
55            *tone = gray8_encode(bits3);
56        }
57
58        Ft8Frame::new(tones)
59    }
60
61    /// Decode an `Ft8Frame` using hard decisions.
62    ///
63    /// Applies inverse Gray code, treats each bit as a ±10 LLR, runs LDPC,
64    /// then verifies the CRC.  Returns the 77-bit payload on success.
65    pub fn decode_hard(frame: &Ft8Frame) -> Option<Ft8Bits> {
66        // Build ±10 LLRs from hard tone decisions
67        let llr = Self::frame_to_llr_hard(frame);
68        Self::decode_llr(&llr)
69    }
70
71    /// Decode using soft LLR values produced by a sync/correlator stage.
72    ///
73    /// `llrs` — 174 floats, LLR = log(P(bit=0)/P(bit=1)), positive ⇒ likely 0.
74    pub fn decode_soft(llr: &[f32; ldpc::N]) -> Option<Ft8Bits> {
75        Self::decode_llr(llr)
76    }
77
78    /// Convert an `Ft8Frame` (hard tone decisions) into 174 LLRs (±10.0).
79    pub fn frame_to_llr_hard(frame: &Ft8Frame) -> [f32; ldpc::N] {
80        let mut llr = [0.0f32; ldpc::N];
81        for (sym_idx, &tone) in frame.0.iter().enumerate() {
82            let bin = gray8_decode(tone);
83            for bit_pos in 0..3usize {
84                let bit = (bin >> (2 - bit_pos)) & 1;
85                llr[sym_idx * 3 + bit_pos] = if bit == 0 { 10.0 } else { -10.0 };
86            }
87        }
88        llr
89    }
90
91    fn decode_llr(llr: &[f32; ldpc::N]) -> Option<Ft8Bits> {
92        let mut plain = [0u8; ldpc::N];
93        let errors = ldpc_decode_soft(llr, 20, &mut plain);
94        if errors != 0 {
95            return None;
96        }
97
98        // Pack the first K bits back into bytes
99        let mut a91 = [0u8; ldpc::K_BYTES];
100        for i in 0..ldpc::K {
101            if plain[i] == 1 {
102                a91[i / 8] |= 0x80 >> (i % 8);
103            }
104        }
105
106        // Verify CRC.
107        //
108        // The CRC covers only the 77-bit payload zero-extended to 82 bits; the
109        // 14 CRC bits themselves (bits 77-90 of a91) must NOT be included.
110        // We therefore zero out the CRC area (bits 77-95 of the buffer) before
111        // calling ft8_crc14 with num_bits=82.  Running ft8_crc14(&a91, 82)
112        // without zeroing would include 5 CRC bits in the computation and
113        // produce a wrong answer.
114        let extracted = ft8_extract_crc(&a91);
115        let mut buf = a91;
116        buf[9] &= 0xF8; // zero bits 77-79 (slack bits, also start of CRC)
117        buf[10] = 0; // zero bits 80-87 (CRC bits 3-10)
118        buf[11] = 0; // zero bits 88-95 (CRC bits 11-13 + unused)
119        let computed = ft8_crc14(&buf, 82);
120        if extracted != computed {
121            return None;
122        }
123
124        // Return the 77 payload bits.  Bits 77-79 of byte 9 are slack; mask
125        // them to zero so callers get a canonical representation.
126        let mut payload = [0u8; 10];
127        payload.copy_from_slice(&a91[..10]);
128        payload[9] &= 0xF8;
129        Some(payload)
130    }
131}
132
133// ── Ft8StreamDecoder ──────────────────────────────────────────────────────────
134
135/// Result of one successfully decoded FT8 or FT4 frame.
136pub struct Ft8DecodeResult {
137    /// Decoded message content.
138    pub message: Ft8Message,
139    /// Tone-0 frequency in Hz (carrier of the detected frame).
140    pub carrier_hz: f32,
141    /// SNR estimate in dB (Costas score, arbitrary but monotone with true SNR).
142    pub snr_db: f32,
143}
144
145/// Accumulates IQ samples at 12 kHz and decodes FT8 or FT4 frames.
146///
147/// Feed samples incrementally with [`feed`].  When the internal buffer reaches
148/// `frame_len` samples, a decode attempt is triggered automatically and the
149/// results are returned.  Call [`flush`] to attempt a decode on whatever is
150/// currently buffered (useful at the end of a session or after a gap).  Call
151/// [`clear`] to discard the buffer without decoding.
152///
153/// The decoder operates at the FT8/FT4 native sample rate of **12 000 Hz**.
154/// Callers receiving samples at a higher rate (e.g. 48 kHz) must decimate
155/// before feeding.
156///
157/// A single [`CallsignHashTable`] is maintained across frames so nonstandard
158/// callsigns hashed in earlier frames can be resolved in later ones.
159pub struct Ft8StreamDecoder {
160    buf: Vec<C32>,
161    fs: f32,
162    base_hz: f32,
163    max_hz: f32,
164    frame_len: usize,
165    is_ft8: bool,
166    max_cand: usize,
167    hash_table: CallsignHashTable,
168}
169
170impl Ft8StreamDecoder {
171    /// Create a decoder for FT8 frames.
172    ///
173    /// - `fs`       — sample rate (should be 12 000 Hz)
174    /// - `base_hz`  — lowest tone-0 frequency to search (Hz)
175    /// - `max_hz`   — highest tone-0 frequency to search (Hz)
176    /// - `max_cand` — maximum sync candidates to score per decode attempt
177    pub fn new_ft8(fs: f32, base_hz: f32, max_hz: f32, max_cand: usize) -> Self {
178        Self {
179            buf: Vec::new(),
180            fs,
181            base_hz,
182            max_hz,
183            frame_len: FT8_FRAME_LEN,
184            is_ft8: true,
185            max_cand: max_cand.max(1),
186            hash_table: CallsignHashTable::new(),
187        }
188    }
189
190    /// Create a decoder for FT4 frames.
191    pub fn new_ft4(fs: f32, base_hz: f32, max_hz: f32, max_cand: usize) -> Self {
192        Self {
193            buf: Vec::new(),
194            fs,
195            base_hz,
196            max_hz,
197            frame_len: FT4_FRAME_LEN,
198            is_ft8: false,
199            max_cand: max_cand.max(1),
200            hash_table: CallsignHashTable::new(),
201        }
202    }
203
204    /// Feed IQ samples into the accumulation buffer.
205    ///
206    /// If the buffer reaches `frame_len` samples after appending, a decode
207    /// attempt is triggered and the results are returned.  Otherwise returns
208    /// an empty `Vec`.
209    pub fn feed(&mut self, iq: &[C32]) -> Vec<Ft8DecodeResult> {
210        self.buf.extend_from_slice(iq);
211        if self.buf.len() >= self.frame_len {
212            self.decode_buf()
213        } else {
214            Vec::new()
215        }
216    }
217
218    /// Attempt a decode on whatever is currently in the buffer.
219    ///
220    /// Useful at a gap edge when the caller knows a frame has ended but the
221    /// buffer may be shorter than `frame_len` (e.g. due to signal dropout).
222    /// Returns decoded results; does NOT clear the buffer.
223    pub fn flush(&mut self) -> Vec<Ft8DecodeResult> {
224        if self.buf.is_empty() {
225            return Vec::new();
226        }
227        self.decode_buf()
228    }
229
230    /// Discard all accumulated samples without decoding.
231    pub fn clear(&mut self) {
232        self.buf.clear();
233    }
234
235    /// Number of accumulated samples.
236    pub fn len(&self) -> usize {
237        self.buf.len()
238    }
239
240    /// True if no samples have been accumulated.
241    pub fn is_empty(&self) -> bool {
242        self.buf.is_empty()
243    }
244
245    /// Read-only view of the accumulated IQ buffer.
246    pub fn view_buf(&self) -> &[C32] {
247        &self.buf
248    }
249
250    // ── Internal ──────────────────────────────────────────────────────────
251
252    fn decode_buf(&mut self) -> Vec<Ft8DecodeResult> {
253        let mut results = Vec::new();
254
255        let tone_spacing = if self.is_ft8 {
256            FT8_TONE_SPACING_HZ
257        } else {
258            FT4_TONE_SPACING_HZ
259        };
260
261        // Clamp search range so base_hz is valid.
262        let search_min = self.base_hz;
263        let search_max = (self.max_hz + tone_spacing).max(search_min + tone_spacing);
264
265        let candidates = if self.is_ft8 {
266            let raw = ft8_sync(
267                &self.buf,
268                self.fs,
269                search_min,
270                search_max,
271                0,
272                0,
273                self.max_cand,
274            );
275            // Convert to unified representation for processing below.
276            raw.into_iter()
277                .map(|r| SyncCandidate {
278                    freq_bin: r.freq_bin,
279                    score: r.score,
280                    llr: r.llr,
281                })
282                .collect::<Vec<_>>()
283        } else {
284            let raw = ft4_sync(
285                &self.buf,
286                self.fs,
287                search_min,
288                search_max,
289                0,
290                0,
291                self.max_cand,
292            );
293            raw.into_iter()
294                .map(|r| SyncCandidate {
295                    freq_bin: r.freq_bin,
296                    score: r.score,
297                    llr: r.llr,
298                })
299                .collect::<Vec<_>>()
300        };
301
302        for cand in candidates {
303            let payload = if self.is_ft8 {
304                Ft8Codec::decode_soft(&cand.llr)
305            } else {
306                crate::codec::ft4::Ft4Codec::decode_soft(&cand.llr)
307            };
308
309            if let Some(bits) = payload {
310                let message = unpack77(&bits, &self.hash_table);
311                let carrier_hz = self.base_hz + cand.freq_bin as f32 * tone_spacing;
312                results.push(Ft8DecodeResult {
313                    message,
314                    carrier_hz,
315                    snr_db: cand.score,
316                });
317                // Stop after the first CRC-passing candidate.
318                break;
319            }
320        }
321
322        results
323    }
324}
325
326/// Internal unified sync result (avoids duplicating processing logic for FT8/FT4).
327struct SyncCandidate {
328    freq_bin: usize,
329    score: f32,
330    llr: [f32; ldpc::N],
331}